@cnrs/hecate-views-charts 0.2.0 → 0.5.0
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 +21 -4
- package/package.json +45 -20
- package/src/init.js +8 -8
- package/src/util.js +13 -0
- package/src/views/all.js +6 -0
- package/src/views/barchart.js +37 -63
- package/src/views/cartesianchart.js +309 -0
- package/src/views/histogram.js +19 -51
- package/src/views/index.js +6 -4
- package/src/views/linechart.js +76 -213
- package/src/views/scatterchart.js +87 -0
- package/src/views/stackedareachart.js +126 -0
- package/src/views/streamgraph.js +39 -0
- package/src/components/index.js +0 -1
- package/src/components/sample.js +0 -37
- package/src/views/scatterplot.js +0 -193
package/README.md
CHANGED
|
@@ -1,15 +1,32 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Hecate ”charts” views
|
|
2
2
|
|
|
3
3
|
<a href='https://datasphere.readthedocs.io/projects/hecate/'><img src='https://gitlab.huma-num.fr/datasphere/doc/assets/-/raw/main/banners/HECATE.png' width='100%'></a>
|
|
4
4
|
|
|
5
|
+
[](https://www.gnu.org/licenses/agpl-3.0.html)
|
|
6
|
+
[](https://www.repostatus.org/#active)
|
|
7
|
+
[](https://gitlab.huma-num.fr/datasphere/hecate/views/charts/pipelines/latest)
|
|
8
|
+
[](https://datasphere.gitpages.huma-num.fr/hecate/views/charts/coverage/)
|
|
9
|
+
[](https://www.npmjs.com/package/@cnrs/hecate-views-charts)
|
|
10
|
+
[](https://datasphere.gitpages.huma-num.fr/hecate/views/charts/doc/)
|
|
11
|
+
|
|
5
12
|
**Hecate** is a free web application for managing scientific databases.
|
|
6
13
|
|
|
7
|
-
**hecate-views
|
|
8
|
-
Views are specific glances on subsets of your scientific database : lists, charts, maps, and so on.
|
|
14
|
+
[**@cnrs/hecate-views-charts**](https://www.npmjs.com/package/@cnrs/hecate-views-charts) is a free Javascript library for visualizing scientific data.
|
|
15
|
+
[Views](https://datasphere.readthedocs.io/projects/hecate/en/latest/design/views.html) are specific glances on subsets of your scientific database : lists, charts, maps, timelines, thumbnails, and so on.
|
|
9
16
|
Each view takes HERA-formatted data ([10.5281/zenodo.12795179](http://doi.org/10.5281/zenodo.12795179)) as its input, and can be easily configured to suit your needs.
|
|
10
17
|
|
|
18
|
+
This package provides the following views:
|
|
19
|
+
|
|
20
|
+
* [BarChart](https://datasphere.gitpages.huma-num.fr/hecate/views/charts/doc/BarChart.html), usable as a `hecate-barchart` HTML element;
|
|
21
|
+
* [Histogram](https://datasphere.gitpages.huma-num.fr/hecate/views/charts/doc/Histogram.html), usable as a `hecate-histogram` HTML element;
|
|
22
|
+
* [LineChart](https://datasphere.gitpages.huma-num.fr/hecate/views/charts/doc/LineChart.html), usable as a `hecate-linechart` HTML element;
|
|
23
|
+
* [ScatterPlot](https://datasphere.gitpages.huma-num.fr/hecate/views/charts/doc/ScatterPlot.html), usable as a `hecate-scatterplot` HTML element;
|
|
24
|
+
* [StackedAreaChart](https://datasphere.gitpages.huma-num.fr/hecate/views/charts/doc/StackedAreaChart.html), usable as a `hecate-stackedareachart` HTML element;
|
|
25
|
+
* [Streamgraph](https://datasphere.gitpages.huma-num.fr/hecate/views/charts/doc/Streamgraph.html), usable as a `hecate-streamgraph` HTML element.
|
|
26
|
+
|
|
11
27
|
## Resources
|
|
12
28
|
|
|
13
29
|
* [Documentation](https://datasphere.readthedocs.io/projects/hecate/)
|
|
14
|
-
* [
|
|
30
|
+
* [API Documentation](https://datasphere.gitpages.huma-num.fr/hecate/views/charts/doc/)
|
|
31
|
+
* [Releases](https://www.npmjs.com/package/@cnrs/hecate-views-charts?activeTab=versions)
|
|
15
32
|
* [Getting help](https://gitlab.huma-num.fr/datasphere/hecate/views/charts/-/issues/)
|
package/package.json
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
+
"name": "@cnrs/hecate-views-charts",
|
|
3
|
+
"description": "Views web components available to any web app.",
|
|
2
4
|
"@comment comments": [
|
|
3
5
|
"To track and understand the whys of each part of this package.json, ",
|
|
4
6
|
"in particular dependencies, please explain when you modify this file.",
|
|
@@ -9,8 +11,15 @@
|
|
|
9
11
|
"",
|
|
10
12
|
"package.json documentation: https://docs.npmjs.com/files/package.json"
|
|
11
13
|
],
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "src/index.js",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src/**/*.js"
|
|
21
|
+
],
|
|
22
|
+
"version": "0.5.0",
|
|
14
23
|
"keywords": [
|
|
15
24
|
"dataviz",
|
|
16
25
|
"research",
|
|
@@ -21,17 +30,7 @@
|
|
|
21
30
|
"browser",
|
|
22
31
|
"library"
|
|
23
32
|
],
|
|
24
|
-
"
|
|
25
|
-
"module": "src/index.js",
|
|
26
|
-
"main": "src/index.js",
|
|
27
|
-
"exports": {
|
|
28
|
-
".": "./src/index.js",
|
|
29
|
-
"default": "./src/index.js"
|
|
30
|
-
},
|
|
31
|
-
"files": [
|
|
32
|
-
"src/**/*.js"
|
|
33
|
-
],
|
|
34
|
-
"version": "0.2.0",
|
|
33
|
+
"author": "The Hecate contributors",
|
|
35
34
|
"license": "AGPL",
|
|
36
35
|
"homepage": "https://datasphere.readthedocs.io/projects/hecate/",
|
|
37
36
|
"repository": {
|
|
@@ -41,8 +40,8 @@
|
|
|
41
40
|
"bugs": {
|
|
42
41
|
"url": "https://gitlab.huma-num.fr/datasphere/hecate/views/charts/-/issues"
|
|
43
42
|
},
|
|
44
|
-
"main": "src/index.js",
|
|
45
43
|
"dependencies": {
|
|
44
|
+
"@cnrs/hecate-views": "^0.3.6",
|
|
46
45
|
"d3": "latest"
|
|
47
46
|
},
|
|
48
47
|
"@comment devDependencies": {
|
|
@@ -56,7 +55,7 @@
|
|
|
56
55
|
"@requires vite ≥ 3.0.0 (and node ≥ 14.18)",
|
|
57
56
|
"- Vitest website: https://vitest.dev/"
|
|
58
57
|
],
|
|
59
|
-
"@vitest/coverage-
|
|
58
|
+
"@vitest/coverage-v8": [
|
|
60
59
|
"Compute percentage of code covered by tests.",
|
|
61
60
|
"- Vitest coverage documentation: https://vitest.dev/guide/coverage.html"
|
|
62
61
|
],
|
|
@@ -66,9 +65,20 @@
|
|
|
66
65
|
"- https://www.npmjs.com/package/happy-dom"
|
|
67
66
|
],
|
|
68
67
|
"eslint": [
|
|
69
|
-
"
|
|
68
|
+
"A linter for analysing ECMAScript code (among others).",
|
|
70
69
|
"- ESLint documentation: https://eslint.org/"
|
|
71
70
|
],
|
|
71
|
+
"@eslint/js": [
|
|
72
|
+
"ESLint plugin specialised in the javascript language.",
|
|
73
|
+
"Needed since javascript-specifics have been separated from the core ESLint.",
|
|
74
|
+
"Referred in the ESLint config file (eslint.config.mjs).",
|
|
75
|
+
"- https://www.npmjs.com/package/@eslint/js"
|
|
76
|
+
],
|
|
77
|
+
"globals": [
|
|
78
|
+
"A collection of identifiers used by ESLint.",
|
|
79
|
+
"Referred in the ESLint config file (eslint.config.mjs).",
|
|
80
|
+
"- https://www.npmjs.com/package/globals"
|
|
81
|
+
],
|
|
72
82
|
"jsdoc": [
|
|
73
83
|
"Javascript documentation generation tool.",
|
|
74
84
|
"See `gendoc` script in this same file for some usage details.",
|
|
@@ -79,8 +89,10 @@
|
|
|
79
89
|
"vite": "latest",
|
|
80
90
|
"vitest": "latest",
|
|
81
91
|
"@vitest/coverage-v8": "latest",
|
|
82
|
-
"happy-dom": "
|
|
92
|
+
"happy-dom": "latest",
|
|
83
93
|
"eslint": "latest",
|
|
94
|
+
"@eslint/js": "latest",
|
|
95
|
+
"globals": "latest",
|
|
84
96
|
"jsdoc": "latest"
|
|
85
97
|
},
|
|
86
98
|
"@comment scripts": {
|
|
@@ -102,13 +114,25 @@
|
|
|
102
114
|
"@requires vitest @vitest/coverage-v8 (dev dependencies)"
|
|
103
115
|
],
|
|
104
116
|
"build": [
|
|
105
|
-
"Bundles project
|
|
117
|
+
"Bundles project in `dist` folder. Please note that:",
|
|
118
|
+
"- we use vite default settings, so don't need any `vite.config.mjs`.",
|
|
119
|
+
"- the result of this build is of no use, as this `package.json` file",
|
|
120
|
+
" says the NPM package contents are `src/**/*.js`, not `dist/*.js`.",
|
|
121
|
+
" We do that because our code is already ESM, Node and modern browsers",
|
|
122
|
+
" compatible, with no need for transpiling (no TypeScript here).",
|
|
123
|
+
"In other words, this command isn't really useful, though I'll keep it",
|
|
124
|
+
"to differentiate it from `buid:cdn`, which IS used.",
|
|
125
|
+
"@requires vite (dev dependency)"
|
|
126
|
+
],
|
|
127
|
+
"build:cdn": [
|
|
128
|
+
"Bundles project for production CDNs in `cdn/dist` folder.",
|
|
129
|
+
"Delivery to NPM must then be made using `cdn/package.json`.",
|
|
106
130
|
"@requires vite (dev dependency)"
|
|
107
131
|
],
|
|
108
132
|
"lint": [
|
|
109
133
|
"Checks code style using ESLint. note: Argument pattern is surrounded with ",
|
|
110
134
|
"double quotes, so its resolution doesn't depend on runtime environment.",
|
|
111
|
-
"@requires eslint (dev
|
|
135
|
+
"@requires eslint and @eslint/js (dev dependencies)",
|
|
112
136
|
"- ESLint configuration file is `eslint.config.mjs`",
|
|
113
137
|
"- ESLint documentation: https://eslint.org/"
|
|
114
138
|
],
|
|
@@ -128,7 +152,8 @@
|
|
|
128
152
|
"test:coverage": "vitest run --coverage",
|
|
129
153
|
"test:watch": "vitest --coverage",
|
|
130
154
|
"build": "vite build",
|
|
131
|
-
"
|
|
155
|
+
"build:cdn": "vite build --config cdn/vite.cdn.config.mjs",
|
|
156
|
+
"lint": "exec eslint \"src/**/*.js\"",
|
|
132
157
|
"doc:html": "./node_modules/jsdoc/jsdoc.js -c .jsdoc.conf.json"
|
|
133
158
|
}
|
|
134
159
|
}
|
package/src/init.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import {
|
|
2
|
+
BarChart,
|
|
3
|
+
Histogram,
|
|
4
|
+
LineChart,
|
|
5
|
+
ScatterChart,
|
|
6
|
+
StackedAreaChart,
|
|
7
|
+
Streamgraph,
|
|
8
|
+
} from './views/index.js'; // eslint-disable-line no-unused-vars
|
package/src/util.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
function getMetadata(item, pid) {
|
|
2
|
+
for (const meta of item['metadata']) {
|
|
3
|
+
if (pid != null && meta['pid'] == pid) return meta['value'];
|
|
4
|
+
}
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
function getProperty(db, pid) { // TODO why can't I use await db.getProperty?
|
|
8
|
+
// faster than return db.properties.filter(e => e.id == pid)[0];
|
|
9
|
+
// we use find(...), which stops as soon as it finds our special something
|
|
10
|
+
return db.properties.find(e => e.id == pid);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { getProperty, getMetadata, };
|
package/src/views/all.js
ADDED
package/src/views/barchart.js
CHANGED
|
@@ -1,89 +1,46 @@
|
|
|
1
|
+
import { View } from '@cnrs/hecate-views';
|
|
1
2
|
import * as d3 from 'd3';
|
|
2
3
|
|
|
3
4
|
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* BarChart web component.
|
|
6
8
|
* @see https://en.wikipedia.org/wiki/Bar_chart
|
|
7
9
|
*/
|
|
8
|
-
class BarChart extends
|
|
10
|
+
class BarChart extends View {
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
this.attachShadow({ mode: 'open' });
|
|
13
|
-
this.hera = {};
|
|
14
|
-
this.settings = {};
|
|
15
|
-
}
|
|
12
|
+
get pidx() { return this.settings.x || 'x'; }
|
|
13
|
+
get pidy() { return this.settings.y || 'y'; }
|
|
16
14
|
|
|
17
|
-
/** Static property returning an array of attributes observed by the browser
|
|
18
|
-
*/
|
|
19
|
-
static get observedAttributes() {
|
|
20
|
-
return ['data-hera', 'data-settings'];
|
|
21
|
-
}
|
|
22
15
|
|
|
23
|
-
get items() { return this.hera.items || []; }
|
|
24
|
-
get entities() { return this.hera.entities || []; }
|
|
25
|
-
get properties() { return this.hera.properties || []; }
|
|
26
16
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
* @param {!string} attribute - Attribute name
|
|
35
|
-
* @param old - Previous value for `attribute`
|
|
36
|
-
* @param now - Current value for `attribute`
|
|
37
|
-
*/
|
|
38
|
-
attributeChangedCallback(attribute, old, now) {
|
|
39
|
-
if (old === now) return; // don't bother if there is nothing new
|
|
40
|
-
this[attribute] = now;
|
|
41
|
-
// data-* attributes can only be strings, so we cannot deserialize JSON
|
|
42
|
-
// in them ; so we use plain object fields to store deserialized values
|
|
43
|
-
// we trim the prefix to get the field name, so 'data-x' becomes 'x'
|
|
44
|
-
const fieldName = attribute.split('-').slice(1).join('-');
|
|
45
|
-
this[fieldName] = JSON.parse(now);
|
|
46
|
-
// note that this does not defeat the purpose of using data-* attributes
|
|
47
|
-
// in the first place, as if a name clash should happen in the future,
|
|
48
|
-
// we could edit this class with no impact on the users code
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
//js method called everytime the component is added to the dom
|
|
52
|
-
connectedCallback(){
|
|
53
|
-
var dataset = this.prepareData(this.hera, this.settings);
|
|
54
|
-
|
|
17
|
+
renderView() {
|
|
18
|
+
// cleanup
|
|
19
|
+
while (this.viewRoot.lastChild.name !== 'view') { // <slot />
|
|
20
|
+
this.viewRoot.removeChild(this.viewRoot.lastChild);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
var dataset = this.prepareData();
|
|
55
24
|
//creation of scene
|
|
56
25
|
const margin = { top : 70, right : 30, bottom: 40, left : 80};
|
|
57
|
-
const {sceneWidth, sceneHeight, barWidth, svg} = this.createScene(500, 500, this.items.length, margin);
|
|
26
|
+
const {sceneWidth, sceneHeight, barWidth, svg} = this.createScene(500, 500, this.db.items.length, margin);
|
|
58
27
|
|
|
59
28
|
//addition of axises
|
|
60
29
|
var {y_scale, x_scale} = this.createAxisScales(sceneHeight, sceneWidth, dataset);
|
|
61
30
|
this.addAxises(x_scale, y_scale, sceneHeight, sceneWidth, margin, svg)
|
|
62
31
|
|
|
63
32
|
this.displayChart(sceneHeight, barWidth, dataset, y_scale, x_scale, svg);
|
|
64
|
-
|
|
65
33
|
}
|
|
66
34
|
|
|
67
35
|
/**
|
|
68
|
-
*
|
|
69
|
-
* @
|
|
70
|
-
|
|
71
|
-
createShadowDom() {
|
|
72
|
-
const e = document.createElement('div');
|
|
73
|
-
this.shadowRoot.appendChild(e);
|
|
74
|
-
return e;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Prepare the data by giving it the correct format
|
|
79
|
-
* @param {object} rawData - the data as it was passed , it's a js object type
|
|
80
|
-
* @param {object} rawSettings - the settings as it was passed, it's a js object type
|
|
81
|
-
* @returns js object that contains the elements of metadata, each key representing a different pid
|
|
36
|
+
* Prepare the data by giving it the correct format.
|
|
37
|
+
* @param {object} db HERA-formatted data as it was passed.
|
|
38
|
+
* @returns {object} Metadata ; each key representing a different pid.
|
|
82
39
|
*/
|
|
83
|
-
prepareData(
|
|
40
|
+
prepareData() {
|
|
84
41
|
var barValues = [];
|
|
85
42
|
var barLabels = [];
|
|
86
|
-
|
|
43
|
+
this.db.items.forEach(item => {
|
|
87
44
|
item.metadata.forEach(meta => {
|
|
88
45
|
if (meta.pid === this.pidy) { // Y axis is height of bars
|
|
89
46
|
barValues.push(parseInt(meta.value));
|
|
@@ -114,7 +71,8 @@ class BarChart extends HTMLElement{
|
|
|
114
71
|
const sceneHeight = height - margin.top - margin.bottom;
|
|
115
72
|
const barWidth = sceneWidth/dataLength
|
|
116
73
|
|
|
117
|
-
const divElem =
|
|
74
|
+
const divElem = document.createElement('div');
|
|
75
|
+
this.viewRoot.appendChild(divElem);
|
|
118
76
|
const container = d3.select(divElem);
|
|
119
77
|
|
|
120
78
|
//addition of container to the svg
|
|
@@ -236,11 +194,27 @@ class BarChart extends HTMLElement{
|
|
|
236
194
|
}
|
|
237
195
|
|
|
238
196
|
getProperty(pid) {
|
|
239
|
-
const found = this.properties.find((p) => p['
|
|
197
|
+
const found = this.db.properties.find((p) => p['id'] === pid);
|
|
240
198
|
return found ? found : null;
|
|
241
199
|
}
|
|
200
|
+
|
|
242
201
|
}
|
|
243
202
|
|
|
244
203
|
|
|
245
204
|
|
|
205
|
+
/** Returns if a string is a valid color.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} value Color (or not).
|
|
208
|
+
* @returns {boolean} `true` if `value` is a valid color, `false` if not.
|
|
209
|
+
*/
|
|
210
|
+
function isColor(value) {
|
|
211
|
+
var style = new Option().style;
|
|
212
|
+
style.color = value; // if value invalid, it won't be assigned
|
|
213
|
+
return style.color == value.toLowerCase();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
customElements.define('hecate-barchart', BarChart);
|
|
219
|
+
|
|
246
220
|
export { BarChart };
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { View } from '@cnrs/hecate-views';
|
|
2
|
+
import * as d3 from 'd3';
|
|
3
|
+
import { getProperty, getMetadata, } from '../util.js';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CartesianChart extends View {
|
|
8
|
+
|
|
9
|
+
get vertical() { return this.settings.orientation === 'vertical' || false; }
|
|
10
|
+
get hEncoding() { return this.vertical? this.settings.y : this.settings.x; }
|
|
11
|
+
get vEncoding() { return this.vertical? this.settings.x : this.settings.y; }
|
|
12
|
+
get reverseX() { return this.settings.x?.axis?.reverse === true || false; }
|
|
13
|
+
get reverseY() { return this.settings.y?.axis?.reverse === true || false; }
|
|
14
|
+
get normalizeX() { return this.settings.x?.normalize === true || false; }
|
|
15
|
+
get normalizeY() { return this.settings.y?.normalize === true || false; }
|
|
16
|
+
get normalizeH() { return this.hEncoding?.normalize === true || false; }
|
|
17
|
+
get normalizeV() { return this.vEncoding?.normalize === true || false; }
|
|
18
|
+
|
|
19
|
+
getItemX(item) { return getMetadata(item, this.settings.x?.pid); }
|
|
20
|
+
getItemY(item) { return getMetadata(item, this.settings.y?.pid); }
|
|
21
|
+
getItemColor(item) { return getMetadata(item, this.settings.color?.pid); }
|
|
22
|
+
|
|
23
|
+
plotX(item, plot, parsers) {
|
|
24
|
+
return plot.vx(parsers.x(this.getItemX(item))) + plot.padding.left;
|
|
25
|
+
}
|
|
26
|
+
plotY(item, plot, parsers) {
|
|
27
|
+
return plot.vy(parsers.y(this.getItemY(item))) + plot.padding.top;
|
|
28
|
+
}
|
|
29
|
+
plotColor(item, plot, parsers) {
|
|
30
|
+
const color = plot.color(parsers.color(this.getItemColor(item)));
|
|
31
|
+
return color ? color : this.settings.color?.value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
renderView() {
|
|
35
|
+
// cleanup
|
|
36
|
+
while (this.viewRoot.lastChild.name !== 'view') { // <slot />
|
|
37
|
+
this.viewRoot.removeChild(this.viewRoot.lastChild);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const container = document.createElement('div');
|
|
41
|
+
container.style = 'width: 100%; height: 100%;';
|
|
42
|
+
this.viewRoot.appendChild(container);
|
|
43
|
+
|
|
44
|
+
const xProperty = getProperty(this.db, this.settings.x?.pid);
|
|
45
|
+
const yProperty = getProperty(this.db, this.settings.y?.pid);
|
|
46
|
+
const cProperty = getProperty(this.db, this.settings.color?.pid);
|
|
47
|
+
const xType = xProperty?.type || 'text';
|
|
48
|
+
const yType = yProperty?.type || 'text';
|
|
49
|
+
const cType = cProperty?.type || 'text';
|
|
50
|
+
const parsers = {
|
|
51
|
+
x: getParser(xType),
|
|
52
|
+
y: getParser(yType),
|
|
53
|
+
color: getParser(cType),
|
|
54
|
+
};
|
|
55
|
+
const scales = {
|
|
56
|
+
x: getScale(xType),
|
|
57
|
+
y: getScale(yType),
|
|
58
|
+
color: getScale(cType),
|
|
59
|
+
};
|
|
60
|
+
const plot = {
|
|
61
|
+
width: this.clientWidth,
|
|
62
|
+
height: this.clientHeight,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
this.prepare(parsers, scales);
|
|
66
|
+
|
|
67
|
+
// Prepare the scales for positional and color encodings.
|
|
68
|
+
// domain = data ; range = render ... cuz a function has:
|
|
69
|
+
// - a domain (its input values),
|
|
70
|
+
// - a codomain (theorical output values), and
|
|
71
|
+
// - a range (effective output values)
|
|
72
|
+
plot.vx = this.createXScale(plot, parsers, scales.x);
|
|
73
|
+
plot.vy = this.createYScale(plot, parsers, scales.y);
|
|
74
|
+
plot.color = this.createColorScale(parsers, scales.color);
|
|
75
|
+
// if this.vertical is true, X axis becomes vertical and Y axis horizontal
|
|
76
|
+
// so we swap them, /but/ they stay named as vx,vy (and not eg. vh,vv)
|
|
77
|
+
// because they really are data scales, and data channels are X,Y
|
|
78
|
+
if (this.vertical) [plot.vx, plot.vy] = [plot.vy, plot.vx];
|
|
79
|
+
// plot.padding is the internal space reserved around the
|
|
80
|
+
// cartesian plotting area, incuding axes, ticks and labels.
|
|
81
|
+
const { hAxis, vAxis, padding } = this.createAxes(plot);
|
|
82
|
+
plot.padding = padding;
|
|
83
|
+
|
|
84
|
+
// Create the SVG container.
|
|
85
|
+
const svgWidth = plot.width + plot.padding.left + plot.padding.right;
|
|
86
|
+
const svgHeight = plot.height + plot.padding.top + plot.padding.bottom;
|
|
87
|
+
const svg = d3.create('svg')
|
|
88
|
+
.attr('width', svgWidth)
|
|
89
|
+
.attr('height', svgHeight)
|
|
90
|
+
.attr('viewBox', [0, 0, svgWidth, svgHeight])
|
|
91
|
+
.attr('style', 'max-width: 100%; height: auto;');
|
|
92
|
+
|
|
93
|
+
// render data
|
|
94
|
+
this.renderData(svg, plot, parsers);
|
|
95
|
+
// render axes
|
|
96
|
+
this.renderHAxis(svg, plot, hAxis);
|
|
97
|
+
this.renderVAxis(svg, plot, vAxis);
|
|
98
|
+
|
|
99
|
+
container.appendChild(svg.node());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
prepare(parsers, scales) { }
|
|
103
|
+
|
|
104
|
+
renderData(svg, plot, parsers) {
|
|
105
|
+
//const { x, y, color } = this.settings;
|
|
106
|
+
const message = //([x, y, color].every(v => v !== undefined)) ?
|
|
107
|
+
`${this.constructor.name} should implement renderData()`
|
|
108
|
+
// : "Missing settings"
|
|
109
|
+
;
|
|
110
|
+
svg.append('text')
|
|
111
|
+
.attr('x', plot.width / 2 + plot.padding.left)
|
|
112
|
+
.attr('y', plot.height / 2 + plot.padding.top)
|
|
113
|
+
.attr('text-anchor', 'middle')
|
|
114
|
+
.attr('fill', 'currentColor')
|
|
115
|
+
.text(`${this.constructor.name} should implement renderData()`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
renderHAxis(svg, plot, axis) {
|
|
119
|
+
const xOffset = plot.padding.left;
|
|
120
|
+
const yOffset = plot.height + plot.padding.top;
|
|
121
|
+
axis.attr('transform', `translate(${xOffset},${yOffset})`);
|
|
122
|
+
if (this.hEncoding?.axis?.domain === false)
|
|
123
|
+
axis.call(g => g.select('.domain').remove());
|
|
124
|
+
svg.append(() => axis.node());
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
renderVAxis(svg, plot, axis) {
|
|
128
|
+
const xOffset = plot.padding.left;
|
|
129
|
+
const yOffset = plot.padding.top;
|
|
130
|
+
axis.attr('transform', `translate(${xOffset},${yOffset})`);
|
|
131
|
+
if (this.vEncoding?.axis?.domain === false)
|
|
132
|
+
axis.call(g => g.select('.domain').remove());
|
|
133
|
+
svg.append(() => axis.node());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
createXScale(plot, parsers, scale) {
|
|
137
|
+
let domain = this.createXDomain(parsers);
|
|
138
|
+
if (this.reverseX) domain = [...domain].reverse();
|
|
139
|
+
const range = this.createXRange(plot);
|
|
140
|
+
return scale().domain(domain).range(range);
|
|
141
|
+
}
|
|
142
|
+
createYScale(plot, parsers, scale) {
|
|
143
|
+
let domain = this.createYDomain(parsers);
|
|
144
|
+
if (this.reverseY) domain = [...domain].reverse();
|
|
145
|
+
const range = this.createYRange(plot);
|
|
146
|
+
return scale().domain(domain).range(range);
|
|
147
|
+
}
|
|
148
|
+
createColorScale(parsers, scale) {
|
|
149
|
+
const cats = new Set();
|
|
150
|
+
for (const item of this.db.items) cats.add(this.getItemColor(item));
|
|
151
|
+
const categories = [...cats]; // Set -> Array
|
|
152
|
+
const domain = this.createColorDomain(categories);
|
|
153
|
+
const range = this.createColorRange();
|
|
154
|
+
return scale().domain(domain).range(range);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
createXDomain(parsers) {
|
|
158
|
+
return d3.extent(this.db.items, item => parsers.x(this.getItemX(item)));
|
|
159
|
+
}
|
|
160
|
+
createYDomain(parsers) {
|
|
161
|
+
return d3.extent(this.db.items, item => parsers.y(this.getItemY(item)));
|
|
162
|
+
}
|
|
163
|
+
createColorDomain(categories) { return categories; }
|
|
164
|
+
createXRange(plot) { return this.vertical ? [plot.height, 0] : [0, plot.width]; }
|
|
165
|
+
createYRange(plot) { return this.vertical ? [0, plot.width] : [plot.height, 0]; }
|
|
166
|
+
createColorRange() { return d3.schemeTableau10; }
|
|
167
|
+
// TODO: provide a fallback palette or generated colors
|
|
168
|
+
// when categories.length > 10 (d3 reuses colors)
|
|
169
|
+
|
|
170
|
+
createAxes(plot) {
|
|
171
|
+
// create a temporary SVG, only for axes bounding box computation
|
|
172
|
+
// these computations allow to deduce necessary paddings
|
|
173
|
+
const tmp = d3.select(this.viewRoot).append('svg')
|
|
174
|
+
.style('position', 'absolute').style('visibility', 'hidden')
|
|
175
|
+
;
|
|
176
|
+
// create horizontal and vertical axes and add them to temporary svg
|
|
177
|
+
const hAxis = this.createHAxis(tmp, plot);
|
|
178
|
+
const vAxis = this.createVAxis(tmp, plot);
|
|
179
|
+
|
|
180
|
+
const hAxisBBox = hAxis.node().getBBox();
|
|
181
|
+
const vAxisBBox = vAxis.node().getBBox();
|
|
182
|
+
|
|
183
|
+
// space around the cartesian plotting area (inc. axes, ticks and labels).
|
|
184
|
+
const padding = {
|
|
185
|
+
top: -vAxisBBox.y, // overflow of first vertical tick label
|
|
186
|
+
right: hAxisBBox.x + hAxisBBox.width - plot.width, // overflow of last horizontal tick label
|
|
187
|
+
bottom: hAxisBBox.height, // room for horizontal axis, inc. ticks and labels
|
|
188
|
+
left: -vAxisBBox.x, // room for vertical axis, inc. ticks and labels
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// add axes labels (if any) and update `padding` (if so)
|
|
192
|
+
if (this.hEncoding?.axis?.name) { // create label for horizontal axis `hAxis`
|
|
193
|
+
const hLabel = this.createHAxisLabel(hAxis, this.hEncoding.axis.name);
|
|
194
|
+
const { x,y } = this.createHAxisLabelOffset(plot, padding, hLabel);
|
|
195
|
+
hLabel.attr('x', x).attr('y', y);
|
|
196
|
+
}
|
|
197
|
+
if (this.vEncoding?.axis?.name) { // create label for vertical axis `vAxis`
|
|
198
|
+
const vLabel = this.createVAxisLabel(vAxis, this.vEncoding.axis.name);
|
|
199
|
+
const { x,y } = this.createVAxisLabelOffset(plot, padding, vLabel);
|
|
200
|
+
vLabel.attr('x', x).attr('y', y);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// padding computations are done, so delete `tmp` svg ; after that,
|
|
204
|
+
// hAxis,vAxis elements are orphaned, allowing them to be added to the
|
|
205
|
+
// real svg `svg`, below
|
|
206
|
+
tmp.remove();
|
|
207
|
+
|
|
208
|
+
return { hAxis, vAxis, padding };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Creates the horizontal axis and adds it to the SVG.
|
|
213
|
+
* @param {d3.Selection} svg SVG element.
|
|
214
|
+
* @param {Plot} plot Coordinate system for `svg`.
|
|
215
|
+
* @returns {d3.Selection} X axis SVG element.
|
|
216
|
+
*/
|
|
217
|
+
createHAxis(svg, plot) {
|
|
218
|
+
const scale = plot.vx;
|
|
219
|
+
const axis = d3.axisBottom(scale);
|
|
220
|
+
if (this.normalizeH) axis.tickFormat(d3.format(".0%"));
|
|
221
|
+
return svg.append('g').call(axis);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
createVAxis(svg, plot) {
|
|
225
|
+
const scale = plot.vy;
|
|
226
|
+
const axis = d3.axisLeft(scale);
|
|
227
|
+
if (this.normalizeV) axis.tickFormat(d3.format(".0%"));
|
|
228
|
+
return svg.append('g').call(axis);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Creates the horizontal axis label, and add it to the horizontal axis.
|
|
233
|
+
* @param {d3.Selection} axis Horizontal axis SVG element (eg. a `<g>`).
|
|
234
|
+
* @param {string} label Horizontal axis label string.
|
|
235
|
+
* @returns {d3.Selection} Horizontal axis label SVG element.
|
|
236
|
+
*/
|
|
237
|
+
createHAxisLabel(axis, label) {
|
|
238
|
+
return axis.append('text')
|
|
239
|
+
.attr('dominant-baseline', 'hanging')
|
|
240
|
+
.attr('fill', 'currentColor')
|
|
241
|
+
.attr('text-anchor', 'end')
|
|
242
|
+
.text(label);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
createVAxisLabel(axis, label) {
|
|
246
|
+
return axis.append('text')
|
|
247
|
+
.attr('dominant-baseline', 'hanging')
|
|
248
|
+
.attr('fill', 'currentColor')
|
|
249
|
+
.attr('text-anchor', 'start')
|
|
250
|
+
.text(label);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
createHAxisLabelOffset(plot, padding, label) {
|
|
254
|
+
const labelBBox = label.node().getBBox();
|
|
255
|
+
// increase the bottom padding so the label fits inside svg viewBox
|
|
256
|
+
padding.bottom += labelBBox.height;
|
|
257
|
+
// position the label so its top aligns with the ticks labels bottom
|
|
258
|
+
return {
|
|
259
|
+
x: plot.width + padding.right,
|
|
260
|
+
y: padding.bottom -labelBBox.height,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
createVAxisLabelOffset(plot, padding, label) {
|
|
265
|
+
const labelBBox = label.node().getBBox();
|
|
266
|
+
const yMax = d3.max(plot.vy.domain());
|
|
267
|
+
const yTop = plot.vy(plot.vy.domain()[1]);
|
|
268
|
+
// increase the top padding so the label fits inside svg viewBox
|
|
269
|
+
padding.top = labelBBox.height - yTop;
|
|
270
|
+
// position the label so its bottom aligns with the upper tick label top
|
|
271
|
+
return {
|
|
272
|
+
x: -padding.left,
|
|
273
|
+
y: yTop - labelBBox.height,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
const PARSERS = {
|
|
281
|
+
'date': d => new Date(d),
|
|
282
|
+
'number': d => +d,
|
|
283
|
+
'text': d => d,
|
|
284
|
+
};
|
|
285
|
+
const SCALES = {
|
|
286
|
+
'date': d3.scaleUtc,
|
|
287
|
+
'number': d3.scaleLinear,
|
|
288
|
+
'text': d3.scaleOrdinal,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
function getParser(type) { return PARSERS[type]; }
|
|
292
|
+
function getScale(type) { return SCALES[type]; }
|
|
293
|
+
|
|
294
|
+
export { CartesianChart };
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Cartesian plotting area.
|
|
298
|
+
*
|
|
299
|
+
* This object defines the coordinate system used to render a chart.
|
|
300
|
+
*
|
|
301
|
+
* @typedef {Object} Plot
|
|
302
|
+
* @property {number} width - Width of the drawable plotting area, excluding padding.
|
|
303
|
+
* @property {number} height - Height of the drawable plotting area, excluding padding.
|
|
304
|
+
* @property {Function} vx - Maps X values from data space to view space.
|
|
305
|
+
* @property {Function} vy - Maps Y values from data space to view space.
|
|
306
|
+
* @property {{top:number,right:number,bottom:number,left:number}} [padding]
|
|
307
|
+
* Internal space reserved for axes, ticks and labels.
|
|
308
|
+
* TODO
|
|
309
|
+
*/
|