feat: line chart with revised encodeable utilities (#26)

* feat: line chart

* feat: implement scale extraction

* refactor: no error

* fix: handle null

* fix: nicing and demo

* fix: legend and demo

* fix: remove commented code

* fix: clean

* fix: reviewer comments

* fix: legend and series

* docs: make demos tsx

* fix: reactnode

* fix: label angle

* fix: resolve labelxxx field names

* docs: try knobs

* feat: improve axis

* refactor: combine computelayout into axisagent

* refactor: cleaner use of scale

* fix: proptypes
This commit is contained in:
Krist Wongsuphasawat 2019-03-28 16:58:22 -07:00 committed by Yongjie Zhao
parent 773de699d8
commit 049b40bc80
60 changed files with 2110 additions and 1098 deletions

View File

@ -39,7 +39,7 @@
"license": "Apache-2.0",
"devDependencies": {
"@superset-ui/build-config": "^0.0.4",
"@superset-ui/chart": "^0.10.2",
"@superset-ui/chart": "^0.10.8",
"@superset-ui/color": "^0.10.0",
"@superset-ui/connection": "^0.10.0",
"@superset-ui/core": "^0.10.0",
@ -47,6 +47,7 @@
"@superset-ui/number-format": "^0.10.0",
"@superset-ui/time-format": "^0.10.0",
"@superset-ui/translation": "^0.10.0",
"@types/react": "^16.8.8",
"csstype": "^2.6.3",
"fast-glob": "^2.2.6",
"fs-extra": "^7.0.1",

View File

@ -35,10 +35,11 @@
"@storybook/addon-knobs": "^4.0.2",
"@storybook/addon-options": "^4.0.3",
"@storybook/react": "^4.1.11",
"@superset-ui/chart": "^0.10.2",
"@superset-ui/chart": "^0.10.8",
"@superset-ui/color": "^0.10.1",
"@superset-ui/time-format": "^0.10.1",
"@superset-ui/translation": "^0.10.0",
"@types/react": "^16.8.8",
"babel-loader": "^8.0.4",
"bootstrap": "^3.3.6",
"cache-loader": "^1.2.2",

View File

@ -1,42 +0,0 @@
/* eslint-disable no-magic-numbers */
import React from 'react';
import { SuperChart } from '@superset-ui/chart';
import data from './data';
export default [
{
renderStory: () => (
<SuperChart
chartType="line2"
chartProps={{
datasource: { verboseMap: {} },
formData: {
bottomMargin: 'auto',
colorScheme: 'd3Category10',
leftMargin: 'auto',
lineInterpolation: 'linear',
richTooltip: true,
showBrush: 'auto',
showLegend: true,
showMarkers: false,
vizType: 'line',
xAxisFormat: '%Y',
xAxisLabel: '',
xAxisShowminmax: false,
xTicksLayout: 'auto',
yAxisBounds: [null, null],
yAxisFormat: '.3s',
yAxisLabel: '',
yAxisShowminmax: false,
yLogScale: false,
},
height: 400,
payload: { data },
width: 400,
}}
/>
),
storyName: 'Basic',
storyPath: 'preset-chart-xy|LineChartPlugin',
},
];

View File

@ -0,0 +1,2 @@
export const LINE_PLUGIN_TYPE = 'v2-line';
export const LINE_PLUGIN_LEGACY_TYPE = 'v2-line/legacy';

View File

@ -0,0 +1,226 @@
/* eslint-disable sort-keys, no-magic-numbers */
export default {
keys: ['name', 'x', 'y'],
values: [
{ x: -157766400000, y: 24703, name: 'Christopher' },
{ x: -126230400000, y: 27861, name: 'Christopher' },
{ x: -94694400000, y: 29436, name: 'Christopher' },
{ x: -63158400000, y: 31463, name: 'Christopher' },
{ x: -31536000000, y: 35718, name: 'Christopher' },
{ x: 0, y: 41758, name: 'Christopher' },
{ x: 31536000000, y: 48172, name: 'Christopher' },
{ x: 63072000000, y: 52092, name: 'Christopher' },
{ x: 94694400000, y: 48217, name: 'Christopher' },
{ x: 126230400000, y: 48476, name: 'Christopher' },
{ x: 157766400000, y: 46438, name: 'Christopher' },
{ x: 189302400000, y: 45086, name: 'Christopher' },
{ x: 220924800000, y: 46610, name: 'Christopher' },
{ x: 252460800000, y: 47107, name: 'Christopher' },
{ x: 283996800000, y: 50514, name: 'Christopher' },
{ x: 315532800000, y: 48969, name: 'Christopher' },
{ x: 347155200000, y: 50108, name: 'Christopher' },
{ x: 378691200000, y: 59055, name: 'Christopher' },
{ x: 410227200000, y: 59188, name: 'Christopher' },
{ x: 441763200000, y: 59859, name: 'Christopher' },
{ x: 473385600000, y: 59516, name: 'Christopher' },
{ x: 504921600000, y: 56633, name: 'Christopher' },
{ x: 536457600000, y: 54466, name: 'Christopher' },
{ x: 567993600000, y: 52996, name: 'Christopher' },
{ x: 599616000000, y: 53205, name: 'Christopher' },
{ x: 631152000000, y: 52322, name: 'Christopher' },
{ x: 662688000000, y: 47109, name: 'Christopher' },
{ x: 694224000000, y: 42470, name: 'Christopher' },
{ x: 725846400000, y: 38257, name: 'Christopher' },
{ x: 757382400000, y: 34823, name: 'Christopher' },
{ x: 788918400000, y: 32728, name: 'Christopher' },
{ x: 820454400000, y: 30988, name: 'Christopher' },
{ x: 852076800000, y: 29179, name: 'Christopher' },
{ x: 883612800000, y: 27083, name: 'Christopher' },
{ x: 915148800000, y: 25700, name: 'Christopher' },
{ x: 946684800000, y: 24959, name: 'Christopher' },
{ x: 978307200000, y: 23180, name: 'Christopher' },
{ x: 1009843200000, y: 21731, name: 'Christopher' },
{ x: 1041379200000, y: 20793, name: 'Christopher' },
{ x: 1072915200000, y: 19739, name: 'Christopher' },
{ x: 1104537600000, y: 19190, name: 'Christopher' },
{ x: 1136073600000, y: 19674, name: 'Christopher' },
{ x: 1167609600000, y: 19986, name: 'Christopher' },
{ x: 1199145600000, y: 17771, name: 'Christopher' },
{ x: -157766400000, y: 67646, name: 'David' },
{ x: -126230400000, y: 66207, name: 'David' },
{ x: -94694400000, y: 66581, name: 'David' },
{ x: -63158400000, y: 63531, name: 'David' },
{ x: -31536000000, y: 63502, name: 'David' },
{ x: 0, y: 61570, name: 'David' },
{ x: 31536000000, y: 52948, name: 'David' },
{ x: 63072000000, y: 46218, name: 'David' },
{ x: 94694400000, y: 40968, name: 'David' },
{ x: 126230400000, y: 41654, name: 'David' },
{ x: 157766400000, y: 39019, name: 'David' },
{ x: 189302400000, y: 39165, name: 'David' },
{ x: 220924800000, y: 40407, name: 'David' },
{ x: 252460800000, y: 40533, name: 'David' },
{ x: 283996800000, y: 41898, name: 'David' },
{ x: 315532800000, y: 41743, name: 'David' },
{ x: 347155200000, y: 40486, name: 'David' },
{ x: 378691200000, y: 40283, name: 'David' },
{ x: 410227200000, y: 39048, name: 'David' },
{ x: 441763200000, y: 38346, name: 'David' },
{ x: 473385600000, y: 38395, name: 'David' },
{ x: 504921600000, y: 37021, name: 'David' },
{ x: 536457600000, y: 36672, name: 'David' },
{ x: 567993600000, y: 35214, name: 'David' },
{ x: 599616000000, y: 35139, name: 'David' },
{ x: 631152000000, y: 33661, name: 'David' },
{ x: 662688000000, y: 30347, name: 'David' },
{ x: 694224000000, y: 28344, name: 'David' },
{ x: 725846400000, y: 26947, name: 'David' },
{ x: 757382400000, y: 24784, name: 'David' },
{ x: 788918400000, y: 22967, name: 'David' },
{ x: 820454400000, y: 22941, name: 'David' },
{ x: 852076800000, y: 21824, name: 'David' },
{ x: 883612800000, y: 20816, name: 'David' },
{ x: 915148800000, y: 20267, name: 'David' },
{ x: 946684800000, y: 19695, name: 'David' },
{ x: 978307200000, y: 19281, name: 'David' },
{ x: 1009843200000, y: 18600, name: 'David' },
{ x: 1041379200000, y: 18557, name: 'David' },
{ x: 1072915200000, y: 18315, name: 'David' },
{ x: 1104537600000, y: 18017, name: 'David' },
{ x: 1136073600000, y: 17510, name: 'David' },
{ x: 1167609600000, y: 17400, name: 'David' },
{ x: 1199145600000, y: 16049, name: 'David' },
{ x: -157766400000, y: 67506, name: 'James' },
{ x: -126230400000, y: 65036, name: 'James' },
{ x: -94694400000, y: 61554, name: 'James' },
{ x: -63158400000, y: 60584, name: 'James' },
{ x: -31536000000, y: 59824, name: 'James' },
{ x: 0, y: 61597, name: 'James' },
{ x: 31536000000, y: 54463, name: 'James' },
{ x: 63072000000, y: 46960, name: 'James' },
{ x: 94694400000, y: 42782, name: 'James' },
{ x: 126230400000, y: 41258, name: 'James' },
{ x: 157766400000, y: 39471, name: 'James' },
{ x: 189302400000, y: 38203, name: 'James' },
{ x: 220924800000, y: 39916, name: 'James' },
{ x: 252460800000, y: 39783, name: 'James' },
{ x: 283996800000, y: 39237, name: 'James' },
{ x: 315532800000, y: 39185, name: 'James' },
{ x: 347155200000, y: 38176, name: 'James' },
{ x: 378691200000, y: 38750, name: 'James' },
{ x: 410227200000, y: 36228, name: 'James' },
{ x: 441763200000, y: 35728, name: 'James' },
{ x: 473385600000, y: 35750, name: 'James' },
{ x: 504921600000, y: 33955, name: 'James' },
{ x: 536457600000, y: 32552, name: 'James' },
{ x: 567993600000, y: 32418, name: 'James' },
{ x: 599616000000, y: 32658, name: 'James' },
{ x: 631152000000, y: 32288, name: 'James' },
{ x: 662688000000, y: 30460, name: 'James' },
{ x: 694224000000, y: 28450, name: 'James' },
{ x: 725846400000, y: 26193, name: 'James' },
{ x: 757382400000, y: 24706, name: 'James' },
{ x: 788918400000, y: 22691, name: 'James' },
{ x: 820454400000, y: 21122, name: 'James' },
{ x: 852076800000, y: 20368, name: 'James' },
{ x: 883612800000, y: 19651, name: 'James' },
{ x: 915148800000, y: 18508, name: 'James' },
{ x: 946684800000, y: 17939, name: 'James' },
{ x: 978307200000, y: 17023, name: 'James' },
{ x: 1009843200000, y: 16905, name: 'James' },
{ x: 1041379200000, y: 16832, name: 'James' },
{ x: 1072915200000, y: 16459, name: 'James' },
{ x: 1104537600000, y: 16046, name: 'James' },
{ x: 1136073600000, y: 16139, name: 'James' },
{ x: 1167609600000, y: 15821, name: 'James' },
{ x: 1199145600000, y: 14920, name: 'James' },
{ x: -157766400000, y: 71390, name: 'John' },
{ x: -126230400000, y: 64858, name: 'John' },
{ x: -94694400000, y: 61480, name: 'John' },
{ x: -63158400000, y: 60754, name: 'John' },
{ x: -31536000000, y: 58644, name: 'John' },
{ x: 0, y: 58348, name: 'John' },
{ x: 31536000000, y: 51382, name: 'John' },
{ x: 63072000000, y: 43028, name: 'John' },
{ x: 94694400000, y: 39061, name: 'John' },
{ x: 126230400000, y: 37553, name: 'John' },
{ x: 157766400000, y: 34970, name: 'John' },
{ x: 189302400000, y: 33876, name: 'John' },
{ x: 220924800000, y: 34103, name: 'John' },
{ x: 252460800000, y: 33895, name: 'John' },
{ x: 283996800000, y: 35305, name: 'John' },
{ x: 315532800000, y: 35131, name: 'John' },
{ x: 347155200000, y: 34761, name: 'John' },
{ x: 378691200000, y: 34560, name: 'John' },
{ x: 410227200000, y: 33047, name: 'John' },
{ x: 441763200000, y: 32484, name: 'John' },
{ x: 473385600000, y: 31397, name: 'John' },
{ x: 504921600000, y: 30103, name: 'John' },
{ x: 536457600000, y: 29462, name: 'John' },
{ x: 567993600000, y: 29301, name: 'John' },
{ x: 599616000000, y: 29751, name: 'John' },
{ x: 631152000000, y: 29011, name: 'John' },
{ x: 662688000000, y: 27727, name: 'John' },
{ x: 694224000000, y: 26156, name: 'John' },
{ x: 725846400000, y: 24918, name: 'John' },
{ x: 757382400000, y: 24119, name: 'John' },
{ x: 788918400000, y: 23174, name: 'John' },
{ x: 820454400000, y: 22104, name: 'John' },
{ x: 852076800000, y: 21330, name: 'John' },
{ x: 883612800000, y: 20556, name: 'John' },
{ x: 915148800000, y: 20280, name: 'John' },
{ x: 946684800000, y: 20032, name: 'John' },
{ x: 978307200000, y: 18839, name: 'John' },
{ x: 1009843200000, y: 17400, name: 'John' },
{ x: 1041379200000, y: 17170, name: 'John' },
{ x: 1072915200000, y: 16381, name: 'John' },
{ x: 1104537600000, y: 15692, name: 'John' },
{ x: 1136073600000, y: 15083, name: 'John' },
{ x: 1167609600000, y: 14348, name: 'John' },
{ x: 1199145600000, y: 13110, name: 'John' },
{ x: -157766400000, y: 80812, name: 'Michael' },
{ x: -126230400000, y: 79709, name: 'Michael' },
{ x: -94694400000, y: 82204, name: 'Michael' },
{ x: -63158400000, y: 81785, name: 'Michael' },
{ x: -31536000000, y: 84893, name: 'Michael' },
{ x: 0, y: 85015, name: 'Michael' },
{ x: 31536000000, y: 77321, name: 'Michael' },
{ x: 63072000000, y: 71197, name: 'Michael' },
{ x: 94694400000, y: 67598, name: 'Michael' },
{ x: 126230400000, y: 67304, name: 'Michael' },
{ x: 157766400000, y: 68149, name: 'Michael' },
{ x: 189302400000, y: 66686, name: 'Michael' },
{ x: 220924800000, y: 67344, name: 'Michael' },
{ x: 252460800000, y: 66875, name: 'Michael' },
{ x: 283996800000, y: 67473, name: 'Michael' },
{ x: 315532800000, y: 68375, name: 'Michael' },
{ x: 347155200000, y: 68467, name: 'Michael' },
{ x: 378691200000, y: 67904, name: 'Michael' },
{ x: 410227200000, y: 67708, name: 'Michael' },
{ x: 441763200000, y: 67457, name: 'Michael' },
{ x: 473385600000, y: 64667, name: 'Michael' },
{ x: 504921600000, y: 63959, name: 'Michael' },
{ x: 536457600000, y: 63442, name: 'Michael' },
{ x: 567993600000, y: 63924, name: 'Michael' },
{ x: 599616000000, y: 65233, name: 'Michael' },
{ x: 631152000000, y: 65138, name: 'Michael' },
{ x: 662688000000, y: 60646, name: 'Michael' },
{ x: 694224000000, y: 54216, name: 'Michael' },
{ x: 725846400000, y: 49443, name: 'Michael' },
{ x: 757382400000, y: 44361, name: 'Michael' },
{ x: 788918400000, y: 41311, name: 'Michael' },
{ x: 820454400000, y: 38284, name: 'Michael' },
{ x: 852076800000, y: 37459, name: 'Michael' },
{ x: 883612800000, y: 36525, name: 'Michael' },
{ x: 915148800000, y: 33820, name: 'Michael' },
{ x: 946684800000, y: 31956, name: 'Michael' },
{ x: 978307200000, y: 29612, name: 'Michael' },
{ x: 1009843200000, y: 28156, name: 'Michael' },
{ x: 1041379200000, y: 27031, name: 'Michael' },
{ x: 1072915200000, y: 25418, name: 'Michael' },
{ x: 1104537600000, y: 23678, name: 'Michael' },
{ x: 1136073600000, y: 22498, name: 'Michael' },
{ x: 1167609600000, y: 21805, name: 'Michael' },
{ x: 1199145600000, y: 20271, name: 'Michael' },
],
};

View File

@ -0,0 +1,94 @@
/* eslint-disable sort-keys, no-magic-numbers */
export default {
keys: ['snapshot', 'x', 'y'],
values: [
{ x: -157766400000, y: 24703, snapshot: 'Last year' },
{ x: -126230400000, y: 27861, snapshot: 'Last year' },
{ x: -94694400000, y: 29436, snapshot: 'Last year' },
{ x: -63158400000, y: 31463, snapshot: 'Last year' },
{ x: -31536000000, y: 35718, snapshot: 'Last year' },
{ x: 0, y: 41758, snapshot: 'Last year' },
{ x: 31536000000, y: 48172, snapshot: 'Last year' },
{ x: 63072000000, y: 52092, snapshot: 'Last year' },
{ x: 94694400000, y: 48217, snapshot: 'Last year' },
{ x: 126230400000, y: 48476, snapshot: 'Last year' },
{ x: 157766400000, y: 46438, snapshot: 'Last year' },
{ x: 189302400000, y: 45086, snapshot: 'Last year' },
{ x: 220924800000, y: 46610, snapshot: 'Last year' },
{ x: 252460800000, y: 47107, snapshot: 'Last year' },
{ x: 283996800000, y: 50514, snapshot: 'Last year' },
{ x: 315532800000, y: 48969, snapshot: 'Last year' },
{ x: 347155200000, y: 50108, snapshot: 'Last year' },
{ x: 378691200000, y: 59055, snapshot: 'Last year' },
{ x: 410227200000, y: 59188, snapshot: 'Last year' },
{ x: 441763200000, y: 59859, snapshot: 'Last year' },
{ x: 473385600000, y: 59516, snapshot: 'Last year' },
{ x: 504921600000, y: 56633, snapshot: 'Last year' },
{ x: 536457600000, y: 54466, snapshot: 'Last year' },
{ x: 567993600000, y: 52996, snapshot: 'Last year' },
{ x: 599616000000, y: 53205, snapshot: 'Last year' },
{ x: 631152000000, y: 52322, snapshot: 'Last year' },
{ x: 662688000000, y: 47109, snapshot: 'Last year' },
{ x: 694224000000, y: 42470, snapshot: 'Last year' },
{ x: 725846400000, y: 38257, snapshot: 'Last year' },
{ x: 757382400000, y: 34823, snapshot: 'Last year' },
{ x: 788918400000, y: 32728, snapshot: 'Last year' },
{ x: 820454400000, y: 30988, snapshot: 'Last year' },
{ x: 852076800000, y: 29179, snapshot: 'Last year' },
{ x: 883612800000, y: 27083, snapshot: 'Last year' },
{ x: 915148800000, y: 25700, snapshot: 'Last year' },
{ x: 946684800000, y: 24959, snapshot: 'Last year' },
{ x: 978307200000, y: 23180, snapshot: 'Last year' },
{ x: 1009843200000, y: 21731, snapshot: 'Last year' },
{ x: 1041379200000, y: 20793, snapshot: 'Last year' },
{ x: 1072915200000, y: 19739, snapshot: 'Last year' },
{ x: 1104537600000, y: 19190, snapshot: 'Last year' },
{ x: 1136073600000, y: 19674, snapshot: 'Last year' },
{ x: 1167609600000, y: 19986, snapshot: 'Last year' },
{ x: 1199145600000, y: 17771, snapshot: 'Last year' },
{ x: -157766400000, y: 80812, snapshot: 'Current' },
{ x: -126230400000, y: 79709, snapshot: 'Current' },
{ x: -94694400000, y: 82204, snapshot: 'Current' },
{ x: -63158400000, y: 81785, snapshot: 'Current' },
{ x: -31536000000, y: 84893, snapshot: 'Current' },
{ x: 0, y: 85015, snapshot: 'Current' },
{ x: 31536000000, y: 77321, snapshot: 'Current' },
{ x: 63072000000, y: 71197, snapshot: 'Current' },
{ x: 94694400000, y: 67598, snapshot: 'Current' },
{ x: 126230400000, y: 67304, snapshot: 'Current' },
{ x: 157766400000, y: 68149, snapshot: 'Current' },
{ x: 189302400000, y: 66686, snapshot: 'Current' },
{ x: 220924800000, y: 67344, snapshot: 'Current' },
{ x: 252460800000, y: 66875, snapshot: 'Current' },
{ x: 283996800000, y: 67473, snapshot: 'Current' },
{ x: 315532800000, y: 68375, snapshot: 'Current' },
{ x: 347155200000, y: 68467, snapshot: 'Current' },
{ x: 378691200000, y: 67904, snapshot: 'Current' },
{ x: 410227200000, y: 67708, snapshot: 'Current' },
{ x: 441763200000, y: 67457, snapshot: 'Current' },
{ x: 473385600000, y: 64667, snapshot: 'Current' },
{ x: 504921600000, y: 63959, snapshot: 'Current' },
{ x: 536457600000, y: 63442, snapshot: 'Current' },
{ x: 567993600000, y: 63924, snapshot: 'Current' },
{ x: 599616000000, y: 65233, snapshot: 'Current' },
{ x: 631152000000, y: 65138, snapshot: 'Current' },
{ x: 662688000000, y: 60646, snapshot: 'Current' },
{ x: 694224000000, y: 54216, snapshot: 'Current' },
{ x: 725846400000, y: 49443, snapshot: 'Current' },
{ x: 757382400000, y: 44361, snapshot: 'Current' },
{ x: 788918400000, y: 41311, snapshot: 'Current' },
{ x: 820454400000, y: 38284, snapshot: 'Current' },
{ x: 852076800000, y: 37459, snapshot: 'Current' },
{ x: 883612800000, y: 36525, snapshot: 'Current' },
{ x: 915148800000, y: 33820, snapshot: 'Current' },
{ x: 946684800000, y: 31956, snapshot: 'Current' },
{ x: 978307200000, y: 29612, snapshot: 'Current' },
{ x: 1009843200000, y: 28156, snapshot: 'Current' },
{ x: 1041379200000, y: 27031, snapshot: 'Current' },
{ x: 1072915200000, y: 25418, snapshot: 'Current' },
{ x: 1104537600000, y: 23678, snapshot: 'Current' },
{ x: 1136073600000, y: 22498, snapshot: 'Current' },
{ x: 1167609600000, y: 21805, snapshot: 'Current' },
{ x: 1199145600000, y: 20271, snapshot: 'Current' },
],
};

View File

@ -1,8 +1,14 @@
import { LineChartPlugin as LegacyLineChartPlugin } from '../../../../../superset-ui-preset-chart-xy/src/legacy';
import { LineChartPlugin } from '../../../../../superset-ui-preset-chart-xy/src';
import Stories from './Stories';
import BasicStories from './stories/basic';
import LegacyStories from './stories/legacy';
import MissingStories from './stories/missing';
import TimeShiftStories from './stories/timeShift';
import { LINE_PLUGIN_TYPE, LINE_PLUGIN_LEGACY_TYPE } from './constants';
new LineChartPlugin().configure({ key: 'line2' }).register();
new LegacyLineChartPlugin().configure({ key: LINE_PLUGIN_LEGACY_TYPE }).register();
new LineChartPlugin().configure({ key: LINE_PLUGIN_TYPE }).register();
export default {
examples: [...Stories],
examples: [...BasicStories, ...MissingStories, ...TimeShiftStories, ...LegacyStories],
};

View File

@ -0,0 +1,59 @@
/* eslint-disable no-magic-numbers, sort-keys */
import * as React from 'react';
import { SuperChart, ChartProps } from '@superset-ui/chart';
import { radios } from '@storybook/addon-knobs';
import data from '../data/data';
import { LINE_PLUGIN_TYPE } from '../constants';
export default [
{
renderStory: () => [
<SuperChart
key="line1"
chartType={LINE_PLUGIN_TYPE}
chartProps={
new ChartProps({
datasource: { verboseMap: {} },
formData: {
encoding: {
x: {
field: 'x',
type: 'temporal',
format: '%Y',
scale: {
type: 'time',
},
axis: {
orient: radios('x.axis.orient', ['top', 'bottom'], 'bottom'),
title: 'Time',
},
},
y: {
field: 'y',
type: 'quantitative',
scale: {
type: 'linear',
},
axis: {
orient: radios('y.axis.orient', ['left', 'right'], 'left'),
title: 'Score',
},
},
color: {
field: 'name',
type: 'nominal',
legend: true,
},
},
},
height: 400,
payload: { data },
width: 400,
})
}
/>,
],
storyName: 'Basic',
storyPath: 'preset-chart-xy|LineChartPlugin',
},
];

View File

@ -0,0 +1,78 @@
/* eslint-disable no-magic-numbers */
import * as React from 'react';
import { SuperChart, ChartProps } from '@superset-ui/chart';
import data from '../data/legacyData';
import { LINE_PLUGIN_LEGACY_TYPE } from '../constants';
export default [
{
renderStory: () => [
<SuperChart
key="line1"
chartType={LINE_PLUGIN_LEGACY_TYPE}
chartProps={
new ChartProps({
datasource: { verboseMap: {} },
formData: {
bottomMargin: 'auto',
colorScheme: 'd3Category10',
leftMargin: 'auto',
lineInterpolation: 'linear',
richTooltip: true,
showBrush: 'auto',
showLegend: true,
showMarkers: false,
vizType: 'line',
xAxisFormat: '%Y',
xAxisLabel: '',
xAxisShowminmax: false,
xTicksLayout: 'auto',
yAxisBounds: [null, null],
yAxisFormat: '',
yAxisLabel: '',
yAxisShowminmax: false,
yLogScale: false,
},
height: 400,
payload: { data },
width: 400,
})
}
/>,
<SuperChart
key="line2"
chartType={LINE_PLUGIN_LEGACY_TYPE}
chartProps={
new ChartProps({
datasource: { verboseMap: {} },
formData: {
bottomMargin: 'auto',
colorScheme: 'd3Category10',
leftMargin: 'auto',
lineInterpolation: 'linear',
richTooltip: true,
showBrush: 'auto',
showLegend: true,
showMarkers: false,
vizType: 'line',
xAxisFormat: '%Y-%m',
xAxisLabel: '',
xAxisShowminmax: false,
xTicksLayout: 'auto',
yAxisBounds: [null, null],
yAxisFormat: '',
yAxisLabel: '',
yAxisShowminmax: false,
yLogScale: false,
},
height: 400,
payload: { data },
width: 800,
})
}
/>,
],
storyName: 'Use Legacy API shim',
storyPath: 'preset-chart-xy|LineChartPlugin',
},
];

View File

@ -0,0 +1,67 @@
/* eslint-disable no-magic-numbers, sort-keys */
import * as React from 'react';
import { SuperChart, ChartProps } from '@superset-ui/chart';
import data from '../data/data';
import { LINE_PLUGIN_TYPE } from '../constants';
const missingData = {
keys: data.keys,
values: data.values.map(({ y, ...rest }) => ({
...rest,
y: Math.random() < 0.05 ? null : y,
})),
};
export default [
{
renderStory: () => [
<SuperChart
key="line1"
chartType={LINE_PLUGIN_TYPE}
chartProps={
new ChartProps({
datasource: { verboseMap: {} },
formData: {
encoding: {
x: {
field: 'x',
type: 'temporal',
format: '%Y',
scale: {
type: 'time',
},
axis: {
orient: 'bottom',
title: 'Time',
},
},
y: {
field: 'y',
type: 'quantitative',
scale: {
type: 'linear',
},
axis: {
orient: 'left',
title: 'Score',
},
},
color: {
field: 'name',
type: 'nominal',
scale: {},
legend: true,
},
},
},
height: 400,
payload: { data: missingData },
width: 400,
})
}
/>,
],
storyName: 'with missing data',
storyPath: 'preset-chart-xy|LineChartPlugin',
},
];

View File

@ -0,0 +1,78 @@
/* eslint-disable no-magic-numbers, sort-keys */
import * as React from 'react';
import { SuperChart, ChartProps } from '@superset-ui/chart';
import data from '../data/data2';
import { LINE_PLUGIN_TYPE } from '../constants';
export default [
{
renderStory: () => [
<SuperChart
key="line1"
chartType={LINE_PLUGIN_TYPE}
chartProps={
new ChartProps({
datasource: { verboseMap: {} },
formData: {
encoding: {
x: {
field: 'x',
type: 'temporal',
format: '%Y',
scale: {
type: 'time',
},
axis: {
orient: 'bottom',
title: 'Time',
},
},
y: {
field: 'y',
type: 'quantitative',
scale: {
type: 'linear',
},
axis: {
orient: 'left',
title: 'Score',
},
},
color: {
value: '#1abc9c',
type: 'nominal',
scale: false,
},
fill: {
field: 'snapshot',
type: 'nominal',
scale: {
type: 'ordinal',
domain: ['Current', 'Last year'],
range: [true, false],
},
legend: false,
},
strokeDasharray: {
field: 'snapshot',
type: 'nominal',
scale: {
type: 'ordinal',
domain: ['Current', 'Last year'],
range: [null, '4 4'],
},
legend: false,
},
},
},
height: 400,
payload: { data },
width: 400,
})
}
/>,
],
storyName: 'with time shift',
storyPath: 'preset-chart-xy|LineChartPlugin',
},
];

View File

@ -3,7 +3,9 @@
[![Version](https://img.shields.io/npm/v/@superset-ui/preset-chart-xy.svg?style=flat-square)](https://img.shields.io/npm/v/@superset-ui/preset-chart-xy.svg?style=flat-square)
[![David (path)](https://img.shields.io/david/apache-superset/superset-ui-plugins.svg?path=packages%2Fsuperset-ui-preset-chart-xy&style=flat-square)](https://david-dm.org/apache-superset/superset-ui-plugins?path=packages/superset-ui-preset-chart-xy)
This plugin provides Box Plot for Superset.
This plugin provides basic charts on cartesian coordinates (Line, Box Plot) for Superset.
> DISCLAIMER: It is still under heavy development and the APIs are subject to changes.
### Usage

View File

@ -1,6 +1,27 @@
import { MarkPropChannelDef, XFieldDef, YFieldDef } from '../encodeable/types/FieldDef';
import AbstractEncoder from '../encodeable/AbstractEncoder';
import { PartialSpec } from '../encodeable/types/Specification';
import { EncodingFromChannelsAndOutputs } from '../encodeable/types/Channel';
/**
* Define channel types
*/
// This is a workaround until TypeScript 3.4 which has const context
// which will allow use to derive type from object literal
// without type widening (e.g. 'X' instead of string).
// Now we have to define class with readonly fields
// to be able to use "typeof" to infer strict types
// See more details from
// https://github.com/Microsoft/TypeScript/issues/20195
// https://github.com/Microsoft/TypeScript/pull/29510
const channelTypes = new class Channels {
readonly x = 'X';
readonly y = 'Y';
readonly color = 'Color';
readonly fill = 'Category';
readonly strokeDasharray = 'Category';
}();
export type ChannelTypes = typeof channelTypes;
/**
* Define output type for each channel
@ -14,18 +35,12 @@ export interface Outputs {
}
/**
* Define encoding config for each channel
* Derive encoding config
*/
export interface Encoding {
x: XFieldDef<Outputs['x']>;
y: YFieldDef<Outputs['y']>;
color: MarkPropChannelDef<Outputs['color']>;
fill: MarkPropChannelDef<Outputs['fill']>;
strokeDasharray: MarkPropChannelDef<Outputs['strokeDasharray']>;
}
export type Encoding = EncodingFromChannelsAndOutputs<ChannelTypes, Outputs>;
export default class Encoder extends AbstractEncoder<Outputs, Encoding> {
static DEFAULT_ENCODINGS: Encoding = {
export default class Encoder extends AbstractEncoder<ChannelTypes, Outputs> {
static readonly DEFAULT_ENCODINGS: Encoding = {
color: { value: '#222' },
fill: { value: false },
strokeDasharray: { value: '' },
@ -33,17 +48,11 @@ export default class Encoder extends AbstractEncoder<Outputs, Encoding> {
y: { field: 'y', type: 'quantitative' },
};
constructor(spec: PartialSpec<Encoding>) {
super(spec, Encoder.DEFAULT_ENCODINGS);
}
createChannels() {
return {
color: this.createChannel('color'),
fill: this.createChannel('fill', { legend: false }),
strokeDasharray: this.createChannel('strokeDasharray'),
x: this.createChannel('x'),
y: this.createChannel('y'),
static readonly CHANNEL_OPTIONS = {
fill: { legend: false },
};
constructor(spec: PartialSpec<Encoding>) {
super(channelTypes, spec, Encoder.DEFAULT_ENCODINGS, Encoder.CHANNEL_OPTIONS);
}
}

View File

@ -1,136 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable sort-keys, no-magic-numbers, complexity */
import PropTypes from 'prop-types';
import React from 'react';
import { LineSeries, XYChart } from '@data-ui/xy-chart';
import { themeShape } from '@data-ui/xy-chart/esm/utils/propShapes';
import { chartTheme } from '@data-ui/theme';
import { CategoricalColorNamespace } from '@superset-ui/color';
import createTooltip from './createTooltip';
import renderLegend from '../utils/renderLegend';
import XYChartLayout from '../utils/XYChartLayout';
import WithLegend from '../components/WithLegend';
chartTheme.gridStyles.stroke = '#f1f3f5';
const propTypes = {
className: PropTypes.string,
data: PropTypes.arrayOf(
PropTypes.shape({
key: PropTypes.string,
values: PropTypes.arrayOf(PropTypes.number),
}),
).isRequired,
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
margin: PropTypes.shape({
top: PropTypes.number,
left: PropTypes.number,
bottom: PropTypes.number,
right: PropTypes.number,
}),
encoding: PropTypes.shape({
x: PropTypes.object,
y: PropTypes.object,
color: PropTypes.object,
}).isRequired,
theme: themeShape,
};
const defaultProps = {
className: '',
margin: { top: 10, right: 10, left: 10, bottom: 10 },
theme: chartTheme,
};
class LineChart extends React.PureComponent {
renderChart({ width, height }) {
const { data, encoding, margin, theme } = this.props;
const config = {
width,
height,
minContentWidth: 0,
minContentHeight: 0,
margin,
theme,
encoding,
};
const colorFn = CategoricalColorNamespace.getScale(
encoding.color.scale.scheme,
encoding.color.scale.namespace,
);
const colorAccessor = encoding.color.accessor;
const children = data.map(series => (
<LineSeries
key={series.key.join('/')}
animated
data={series.values}
stroke={colorFn(colorAccessor(series))}
strokeWidth={1.5}
/>
));
const layout = new XYChartLayout({ ...config, children });
return layout.createChartWithFrame(dim => (
<XYChart
width={dim.width}
height={dim.height}
ariaLabel="BoxPlot"
margin={layout.margin}
renderTooltip={createTooltip(encoding.y.axis.tickFormat)}
showYGrid
// snapTooltipToDataX
theme={config.theme}
xScale={config.encoding.x.scale}
yScale={config.encoding.y.scale}
>
{children}
{layout.createXAxis()}
{layout.createYAxis()}
</XYChart>
));
}
render() {
const { className, data, width, height, encoding } = this.props;
return (
<WithLegend
className={`superset-chart-line ${className}`}
width={width}
height={height}
position="top"
renderLegend={() => renderLegend(data, encoding.color)}
renderChart={parent => this.renderChart(parent)}
hideLegend={!encoding.color.legend}
/>
);
}
}
LineChart.propTypes = propTypes;
LineChart.defaultProps = defaultProps;
export default LineChart;

View File

@ -0,0 +1,226 @@
/* eslint-disable sort-keys, no-magic-numbers, complexity */
import React, { PureComponent } from 'react';
import {
AreaSeries,
LinearGradient,
LineSeries,
XYChart,
CrossHair,
WithTooltip,
} from '@data-ui/xy-chart';
import { chartTheme, ChartTheme } from '@data-ui/theme';
import { Margin, Dimension } from '@superset-ui/dimension';
import { groupBy, flatMap, uniqueId, values } from 'lodash';
import createTooltip from './createTooltip';
import XYChartLayout from '../utils/XYChartLayout';
import WithLegend from '../components/WithLegend';
import Encoder, { ChannelTypes, Encoding, Outputs } from './Encoder';
import { Dataset, PlainObject } from '../encodeable/types/Data';
import ChartLegend from '../components/ChartLegend';
chartTheme.gridStyles.stroke = '#f1f3f5';
const defaultProps = {
className: '',
margin: { top: 20, right: 20, left: 20, bottom: 20 },
theme: chartTheme,
};
type Props = {
className?: string;
width: string | number;
height: string | number;
margin?: Margin;
encoding: Encoding;
data: Dataset;
theme?: ChartTheme;
} & Readonly<typeof defaultProps>;
export interface Series {
key: string;
color: Outputs['color'];
fill: Outputs['fill'];
strokeDasharray: Outputs['strokeDasharray'];
values: SeriesValue[];
}
export interface SeriesValue {
x: Outputs['x'];
y: Outputs['y'];
data: PlainObject;
parent: Series;
}
const CIRCLE_STYLE = { strokeWidth: 1.5 };
class LineChart extends PureComponent<Props> {
static defaultProps = defaultProps;
constructor(props: Props) {
super(props);
const { encoding } = this.props;
this.encoder = new Encoder({ encoding });
this.renderChart = this.renderChart.bind(this);
}
encoder: Encoder;
renderChart(dim: Dimension) {
const { width, height } = dim;
const { data, encoding, margin, theme } = this.props;
const fieldNames = data.keys
.filter(k => k !== encoding.x.field && k !== encoding.y.field)
.sort((a, b) => a.localeCompare(b));
const groups = groupBy(data.values, row => fieldNames.map(f => `${f}=${row[f]}`).join(','));
const allSeries = values(groups).map(seriesData => {
const firstDatum = seriesData[0];
const series: Series = {
key: fieldNames.map(f => firstDatum[f]).join(','),
color: this.encoder.channels.color.encode(firstDatum),
fill: this.encoder.channels.fill.encode(firstDatum, false),
strokeDasharray: this.encoder.channels.strokeDasharray.encode(firstDatum),
values: [],
};
series.values = seriesData.map(v => ({
x: this.encoder.channels.x.encode(v),
y: this.encoder.channels.y.encode(v),
data: v,
parent: series,
}));
return series;
});
const filledSeries = flatMap(
allSeries
.filter(({ fill }) => fill)
.map(series => {
const gradientId = uniqueId(`gradient-${series.key}`);
return [
<LinearGradient
key={`${series.key}-gradient`}
id={gradientId}
from={series.color}
to="#fff"
/>,
<AreaSeries
key={`${series.key}-fill`}
data={series.values}
interpolation="linear"
fill={`url(#${gradientId})`}
stroke={series.color}
strokeWidth={1.5}
/>,
];
}),
);
const unfilledSeries = allSeries
.filter(({ fill }) => !fill)
.map(series => (
<LineSeries
key={series.key}
seriesKey={series.key}
interpolation="linear"
data={series.values}
stroke={series.color}
strokeDasharray={series.strokeDasharray}
strokeWidth={1.5}
/>
));
const children = filledSeries.concat(unfilledSeries);
const layout = new XYChartLayout({
width,
height,
margin,
theme,
xEncoder: this.encoder.channels.x,
yEncoder: this.encoder.channels.y,
children,
});
return layout.renderChartWithFrame((chartDim: Dimension) => (
<WithTooltip renderTooltip={createTooltip(this.encoder, allSeries)}>
{({
onMouseLeave,
onMouseMove,
tooltipData,
}: {
onMouseLeave: (...args: any[]) => void;
onMouseMove: (...args: any[]) => void;
tooltipData: any;
}) => (
<XYChart
width={chartDim.width}
height={chartDim.height}
ariaLabel="LineChart"
eventTrigger="container"
margin={layout.margin}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
renderTooltip={null}
showYGrid
snapTooltipToDataX
theme={theme}
tooltipData={tooltipData}
xScale={this.encoder.channels.x.definition.scale}
yScale={this.encoder.channels.y.definition.scale}
>
{children}
{layout.renderXAxis()}
{layout.renderYAxis()}
<CrossHair
fullHeight
strokeDasharray=""
showHorizontalLine={false}
circleFill={(d: SeriesValue) =>
d.y === tooltipData.datum.y ? d.parent.color : '#fff'
}
circleSize={(d: SeriesValue) => (d.y === tooltipData.datum.y ? 6 : 4)}
circleStroke={(d: SeriesValue) =>
d.y === tooltipData.datum.y ? '#fff' : d.parent.color
}
circleStyles={CIRCLE_STYLE}
stroke="#ccc"
showCircle
showMultipleCircles
/>
</XYChart>
)}
</WithTooltip>
));
}
render() {
const { className, data, width, height, encoding } = this.props;
this.encoder = new Encoder({ encoding });
const renderLegend = this.encoder.hasLegend()
? // eslint-disable-next-line react/jsx-props-no-multi-spaces
() => <ChartLegend<ChannelTypes, Outputs, Encoding> data={data} encoder={this.encoder} />
: undefined;
return (
<WithLegend
className={`superset-chart-line ${className}`}
width={width}
height={height}
position="top"
renderLegend={renderLegend}
renderChart={this.renderChart}
/>
);
}
}
export default LineChart;

View File

@ -1,5 +0,0 @@
import React from 'react';
export default function createTooltip() {
return () => <div>tooltip!</div>;
}

View File

@ -0,0 +1,50 @@
/* eslint-disable no-magic-numbers */
import React from 'react';
import TooltipFrame from '../components/tooltip/TooltipFrame';
import TooltipTable from '../components/tooltip/TooltipTable';
import { Series, SeriesValue } from './Line';
import Encoder from './Encoder';
export default function createTooltip(encoder: Encoder, allSeries: Series[]) {
function LineTooltip({
datum,
series = {},
}: {
datum: SeriesValue;
series: {
[key: string]: {
y: number;
};
};
}) {
return (
<TooltipFrame>
<React.Fragment>
<div>
<strong>{encoder.channels.x.formatValue(datum.x)}</strong>
</div>
<br />
{series && (
<TooltipTable
data={allSeries
.filter(({ key }) => series[key])
.concat()
.sort((a, b) => series[b.key].y - series[a.key].y)
.map(({ key, color }) => ({
key,
keyStyle: {
color,
fontWeight: series[key] === datum ? 600 : 200,
},
value: encoder.channels.y.formatValue(series[key].y),
}))}
/>
)}
</React.Fragment>
</TooltipFrame>
);
}
return LineTooltip;
}

View File

@ -1,39 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@superset-ui/translation';
import { ChartMetadata, ChartPlugin } from '@superset-ui/chart';
import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';
const metadata = new ChartMetadata({
description: '',
name: t('Box Plot'),
thumbnail,
useLegacyApi: true,
});
export default class LineChartPlugin extends ChartPlugin {
constructor() {
super({
loadChart: () => import('./Line'),
metadata,
transformProps,
});
}
}

View File

@ -0,0 +1,13 @@
import { ChartPlugin } from '@superset-ui/chart';
import metadata from './metadata';
import transformProps from './transformProps';
export default class LineChartPlugin extends ChartPlugin {
constructor() {
super({
loadChart: () => import('./Line'),
metadata,
transformProps,
});
}
}

View File

@ -0,0 +1,13 @@
import { ChartPlugin } from '@superset-ui/chart';
import metadata from './metadata';
import transformProps from './transformProps';
export default class LineChartPlugin extends ChartPlugin {
constructor() {
super({
loadChart: () => import('../Line'),
metadata,
transformProps,
});
}
}

View File

@ -0,0 +1,6 @@
import metadata from '../metadata';
const legacyMetadata = metadata.clone();
legacyMetadata.useLegacyApi = true;
export default legacyMetadata;

View File

@ -0,0 +1,67 @@
/* eslint-disable sort-keys */
import { ChartProps } from '@superset-ui/chart';
import { flatMap } from 'lodash';
interface DataRow {
key: string[];
values: {
x: number;
y: number;
}[];
}
export default function transformProps(chartProps: ChartProps) {
const { width, height, formData, payload } = chartProps;
const { colorScheme, xAxisLabel, xAxisFormat, yAxisLabel, yAxisFormat } = formData;
const data = payload.data as DataRow[];
return {
data: {
keys: ['name', 'x', 'y'],
values: flatMap(
data.map((row: DataRow) =>
row.values.map(v => ({
...v,
name: row.key[0],
})),
),
),
},
width,
height,
encoding: {
x: {
field: 'x',
type: 'temporal',
format: xAxisFormat,
scale: {
type: 'time',
},
axis: {
orient: 'bottom',
title: xAxisLabel,
},
},
y: {
field: 'y',
type: 'quantitative',
format: yAxisFormat,
scale: {
type: 'linear',
},
axis: {
orient: 'left',
title: yAxisLabel,
},
},
color: {
field: 'name',
type: 'nominal',
scale: {
scheme: colorScheme,
},
legend: true,
},
},
};
}

View File

@ -0,0 +1,11 @@
import { t } from '@superset-ui/translation';
import { ChartMetadata } from '@superset-ui/chart';
import thumbnail from './images/thumbnail.png';
const metadata = new ChartMetadata({
description: '',
name: t('Line Chart'),
thumbnail,
});
export default metadata;

View File

@ -1,47 +0,0 @@
import { getNumberFormatter } from '@superset-ui/number-format';
import { getTimeFormatter } from '@superset-ui/time-format';
/* eslint-disable sort-keys */
export default function transformProps(chartProps) {
const { width, height, formData, payload } = chartProps;
const { colorScheme, xAxisLabel, xAxisFormat, yAxisLabel, yAxisFormat } = formData;
return {
data: payload.data,
width,
height,
encoding: {
x: {
accessor: d => d.x,
scale: {
type: 'time',
},
axis: {
orientation: 'bottom',
label: xAxisLabel,
numTicks: 5,
tickFormat: getTimeFormatter(xAxisFormat),
},
},
y: {
accessor: d => d.y,
scale: {
type: 'linear',
},
axis: {
orientation: 'left',
label: yAxisLabel,
tickFormat: getNumberFormatter(yAxisFormat),
},
},
color: {
accessor: d => d.key.join('/'),
scale: {
scheme: colorScheme,
},
legend: true,
},
},
};
}

View File

@ -0,0 +1,16 @@
import { ChartProps } from '@superset-ui/chart';
/* eslint-disable sort-keys */
export default function transformProps(chartProps: ChartProps) {
const { width, height, formData, payload } = chartProps;
const { encoding } = formData;
const { data } = payload;
return {
data,
width,
height,
encoding,
};
}

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { PureComponent } from 'react';
import { isDefined } from '@superset-ui/core';
function checkNumber(input: any): input is number {
@ -9,11 +9,11 @@ type Props = {
contentWidth?: number;
contentHeight?: number;
height: number;
renderContent: ({ height, width }: { height: number; width: number }) => React.ReactElement;
renderContent: ({ height, width }: { height: number; width: number }) => React.ReactNode;
width: number;
};
export default class ChartFrame extends React.PureComponent<Props, {}> {
export default class ChartFrame extends PureComponent<Props, {}> {
static defaultProps = {
renderContent() {},
};

View File

@ -0,0 +1,91 @@
import React, { CSSProperties, PureComponent } from 'react';
import { scaleOrdinal } from '@vx/scale';
import { LegendOrdinal, LegendItem, LegendLabel } from '@vx/legend';
import { Value } from 'vega-lite/build/src/fielddef';
import AbstractEncoder from '../encodeable/AbstractEncoder';
import { Dataset } from '../encodeable/types/Data';
import { ObjectWithKeysFromAndValueType } from '../encodeable/types/Base';
import { ChannelType, EncodingFromChannelsAndOutputs } from '../encodeable/types/Channel';
import { BaseOptions } from '../encodeable/types/Specification';
type Props<Encoder> = {
data: Dataset;
encoder: Encoder;
};
interface Label {
text: string;
value: string;
}
const MARK_SIZE = 8;
const MARK_STYLE: CSSProperties = { display: 'inline-block' };
const LABEL_STYLE: CSSProperties = { display: 'flex', flexDirection: 'row', flexWrap: 'wrap' };
const LEGEND_CONTAINER_STYLE: CSSProperties = {
maxHeight: 100,
overflowY: 'hidden',
paddingLeft: 14,
paddingTop: 6,
position: 'relative',
};
export default class ChartLegend<
ChannelTypes extends ObjectWithKeysFromAndValueType<Outputs, ChannelType>,
Outputs extends ObjectWithKeysFromAndValueType<Encoding, Value>,
Encoding extends EncodingFromChannelsAndOutputs<
ChannelTypes,
Outputs
> = EncodingFromChannelsAndOutputs<ChannelTypes, Outputs>,
Options extends BaseOptions = BaseOptions
> extends PureComponent<Props<AbstractEncoder<ChannelTypes, Outputs, Encoding, Options>>, {}> {
render() {
const { data, encoder } = this.props;
const legends = Object.keys(encoder.legends).map((field: string) => {
const channelNames = encoder.legends[field];
const channelEncoder = encoder.channels[channelNames[0]];
const domain = Array.from(new Set(data.values.map(channelEncoder.get)));
const scale = scaleOrdinal({
domain,
range: domain.map((key: string) => channelEncoder.encodeValue(key)),
});
return (
<div key={field} style={LEGEND_CONTAINER_STYLE}>
<LegendOrdinal scale={scale} labelFormat={channelEncoder.formatValue}>
{(labels: Label[]) => (
<div style={LABEL_STYLE}>
{labels.map((label: Label) => (
<LegendItem
key={`legend-quantile-${label.text}`}
margin="0 5px"
onClick={() => {
alert(`clicked: ${JSON.stringify(label)}`);
}}
>
<svg width={MARK_SIZE} height={MARK_SIZE} style={MARK_STYLE}>
<circle
fill={label.value}
r={MARK_SIZE / 2}
cx={MARK_SIZE / 2}
cy={MARK_SIZE / 2}
/>
</svg>
<LegendLabel align="left" margin="0 0 0 4px">
{label.text}
</LegendLabel>
</LegendItem>
))}
</div>
)}
</LegendOrdinal>
</div>
);
});
return <React.Fragment>{legends}</React.Fragment>;
}
}

View File

@ -1,27 +1,17 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable sort-keys */
import React, { CSSProperties, ReactNode } from 'react';
import React, { CSSProperties, ReactNode, PureComponent } from 'react';
import { ParentSize } from '@vx/responsive';
// eslint-disable-next-line import/no-unresolved
import * as CSS from 'csstype';
const defaultProps = {
className: '',
height: 'auto' as number | string,
width: 'auto' as number | string,
legendJustifyContent: undefined,
position: 'top',
};
type Props = {
className: string;
width: number | string;
@ -29,35 +19,27 @@ type Props = {
legendJustifyContent: 'center' | 'flex-start' | 'flex-end';
position: 'top' | 'left' | 'bottom' | 'right';
renderChart: (dim: { width: number; height: number }) => ReactNode;
renderLegend: (params: { direction: string }) => ReactNode;
hideLegend: boolean;
};
renderLegend?: (params: { direction: string }) => ReactNode;
} & Readonly<typeof defaultProps>;
const LEGEND_STYLE_BASE: CSSProperties = {
display: 'flex',
flexGrow: 0,
flexShrink: 0,
fontSize: '0.9em',
order: -1,
paddingTop: '5px',
fontSize: '0.9em',
};
const CHART_STYLE_BASE: CSSProperties = {
flexBasis: 'auto',
flexGrow: 1,
flexShrink: 1,
flexBasis: 'auto',
position: 'relative',
};
class WithLegend extends React.PureComponent<Props, {}> {
static defaultProps = {
className: '',
width: 'auto',
height: 'auto',
legendJustifyContent: undefined,
position: 'top',
hideLegend: false,
};
class WithLegend extends PureComponent<Props, {}> {
static defaultProps = defaultProps;
getContainerDirection(): CSS.FlexDirectionProperty {
const { position } = this.props;
@ -93,15 +75,7 @@ class WithLegend extends React.PureComponent<Props, {}> {
}
render() {
const {
className,
width,
height,
position,
renderChart,
renderLegend,
hideLegend,
} = this.props;
const { className, width, height, position, renderChart, renderLegend } = this.props;
const isHorizontal = position === 'left' || position === 'right';
@ -132,7 +106,7 @@ class WithLegend extends React.PureComponent<Props, {}> {
return (
<div className={`with-legend ${className}`} style={style}>
{!hideLegend && (
{renderLegend && (
<div className="legend-container" style={legendStyle}>
{renderLegend({
// Pass flexDirection for @vx/legend to arrange legend items

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { PureComponent } from 'react';
type Props = {
className?: string;
@ -7,7 +7,7 @@ type Props = {
const CONTAINER_STYLE = { padding: 8 };
class TooltipFrame extends React.PureComponent<Props, {}> {
class TooltipFrame extends PureComponent<Props, {}> {
static defaultProps = {
className: '',
};

View File

@ -1,4 +1,4 @@
import React, { CSSProperties } from 'react';
import React, { CSSProperties, PureComponent } from 'react';
type Props = {
className?: string;
@ -12,7 +12,7 @@ type Props = {
const VALUE_CELL_STYLE: CSSProperties = { paddingLeft: 8, textAlign: 'right' };
export default class TooltipTable extends React.PureComponent<Props, {}> {
export default class TooltipTable extends PureComponent<Props, {}> {
static defaultProps = {
className: '',
data: [],

View File

@ -1,52 +1,80 @@
import { Value } from 'vega-lite/build/src/fielddef';
import ChannelEncoder from './ChannelEncoder';
import { ChannelOptions } from './types/Channel';
import { ChannelDef, isFieldDef } from './types/FieldDef';
import { ObjectWithKeysFromAndValueType } from './types/Base';
import { ChannelOptions, EncodingFromChannelsAndOutputs, ChannelType } from './types/Channel';
import { FullSpec, BaseOptions, PartialSpec } from './types/Specification';
export type ObjectWithKeysFromAndValueType<T extends {}, V> = { [key in keyof T]: V };
export type ChannelOutputs<T> = ObjectWithKeysFromAndValueType<T, Value>;
export type BaseEncoding<Output extends ObjectWithKeysFromAndValueType<Output, Value>> = {
[key in keyof Output]: ChannelDef<Output[key]>
};
export type Channels<
Outputs extends ChannelOutputs<Encoding>,
Encoding extends BaseEncoding<Outputs>
> = { readonly [k in keyof Outputs]: ChannelEncoder<Encoding[k], Outputs[k]> };
import { isFieldDef } from './types/FieldDef';
import ChannelEncoder from './ChannelEncoder';
export default abstract class AbstractEncoder<
Outputs extends ChannelOutputs<Encoding>,
Encoding extends BaseEncoding<Outputs>,
// The first 3 generics depends on each other
// to ensure all of them will have the exact same keys
ChannelTypes extends ObjectWithKeysFromAndValueType<Outputs, ChannelType>,
Outputs extends ObjectWithKeysFromAndValueType<Encoding, Value>,
Encoding extends EncodingFromChannelsAndOutputs<
ChannelTypes,
Outputs
> = EncodingFromChannelsAndOutputs<ChannelTypes, Outputs>,
Options extends BaseOptions = BaseOptions
> {
readonly channelTypes: ChannelTypes;
readonly spec: FullSpec<Encoding, Options>;
readonly channels: Channels<Outputs, Encoding>;
readonly legends: {
[key: string]: (keyof Encoding)[];
readonly channels: {
readonly [k in keyof ChannelTypes]: ChannelEncoder<Encoding[k], Outputs[k]>
};
constructor(spec: PartialSpec<Encoding, Options>, defaultEncoding?: Encoding) {
readonly legends: {
[key: string]: (keyof ChannelTypes)[];
};
constructor(
channelTypes: ChannelTypes,
spec: PartialSpec<Encoding, Options>,
defaultEncoding?: Encoding,
channelOptions: Partial<{ [k in keyof ChannelTypes]: ChannelOptions }> = {},
) {
this.channelTypes = channelTypes;
this.spec = this.createFullSpec(spec, defaultEncoding);
this.channels = this.createChannels();
this.legends = {};
type ChannelName = keyof ChannelTypes;
type Channels = { readonly [k in ChannelName]: ChannelEncoder<Encoding[k], Outputs[k]> };
const channelNames = Object.keys(this.channelTypes) as ChannelName[];
const { encoding } = this.spec;
this.channels = channelNames
.map(
(name: ChannelName) =>
new ChannelEncoder<Encoding[typeof name], Outputs[typeof name]>({
definition: encoding[name],
name,
options: {
...this.spec.options,
...channelOptions[name],
},
type: channelTypes[name],
}),
)
.reduce((prev: Partial<Channels>, curr) => {
const all = prev;
all[curr.name as ChannelName] = curr;
return all;
}, {}) as Channels;
// Group the channels that use the same field together
// so they can share the same legend.
(Object.keys(this.channels) as (keyof Encoding)[])
.map((key: keyof Encoding) => this.channels[key])
this.legends = {};
channelNames
.map((name: ChannelName) => this.channels[name])
.filter(c => c.hasLegend())
.forEach(c => {
if (isFieldDef(c.definition)) {
const key = c.name as keyof Encoding;
const name = c.name as ChannelName;
const { field } = c.definition;
if (this.legends[field]) {
this.legends[field].push(key);
this.legends[field].push(name);
} else {
this.legends[field] = [key];
this.legends[field] = [name];
}
}
});
@ -71,27 +99,6 @@ export default abstract class AbstractEncoder<
};
}
protected createChannel<ChannelName extends keyof Outputs>(
name: ChannelName,
options?: ChannelOptions,
) {
const { encoding } = this.spec;
return new ChannelEncoder<Encoding[ChannelName], Outputs[ChannelName]>(
`${name}`,
encoding[name],
{
...this.spec.options,
...options,
},
);
}
/**
* subclass should override this
*/
protected abstract createChannels(): Channels<Outputs, Encoding>;
hasLegend() {
return Object.keys(this.legends).length > 0;
}

View File

@ -0,0 +1,177 @@
/* eslint-disable no-magic-numbers */
import { CSSProperties } from 'react';
import { Value } from 'vega-lite/build/src/fielddef';
import { getTextDimension } from '@superset-ui/dimension';
import { CategoricalColorScale } from '@superset-ui/color';
import { extractFormatFromTypeAndFormat } from './parsers/extractFormat';
import { CoreAxis, LabelOverlapStrategy } from './types/Axis';
import { PositionFieldDef, ChannelDef } from './types/FieldDef';
import ChannelEncoder from './ChannelEncoder';
import { DEFAULT_LABEL_ANGLE } from '../utils/constants';
const DEFAULT_BASE_CONFIG: {
labelOverlap: LabelOverlapStrategy;
labelPadding: number;
tickCount: number;
} = {
labelOverlap: 'auto',
labelPadding: 4,
tickCount: 5,
};
const DEFAULT_X_CONFIG: CoreAxis = {
...DEFAULT_BASE_CONFIG,
labelAngle: DEFAULT_LABEL_ANGLE,
orient: 'bottom',
};
const DEFAULT_Y_CONFIG: CoreAxis = {
...DEFAULT_BASE_CONFIG,
labelAngle: 0,
orient: 'left',
};
export default class AxisAgent<Def extends ChannelDef<Output>, Output extends Value = Value> {
private readonly channelEncoder: ChannelEncoder<Def, Output>;
private readonly format?: (value: any) => string;
readonly config: CoreAxis;
constructor(channelEncoder: ChannelEncoder<Def, Output>) {
this.channelEncoder = channelEncoder;
const definition = channelEncoder.definition as PositionFieldDef;
const { type, axis = {} } = definition;
this.config = this.channelEncoder.isX()
? { ...DEFAULT_X_CONFIG, ...axis }
: { ...DEFAULT_Y_CONFIG, ...axis };
if (typeof axis.format !== 'undefined') {
this.format = extractFormatFromTypeAndFormat(type, axis.format);
}
}
getFormat() {
return this.format || this.channelEncoder.formatValue;
}
getTitle() {
return this.config.title || this.channelEncoder.getTitle();
}
getTickLabels() {
const { tickCount, values } = this.config;
const format = this.getFormat();
if (typeof values !== 'undefined') {
return (values as any[]).map(format);
}
if (typeof this.channelEncoder.scale !== 'undefined') {
const { scale } = this.channelEncoder.scale;
if (typeof scale !== 'undefined' && !(scale instanceof CategoricalColorScale)) {
return ('ticks' in scale && typeof scale.ticks !== 'undefined'
? scale.ticks(tickCount)
: scale.domain()
).map(format);
}
}
return [];
}
computeLayout({
axisLabelHeight = 20,
axisWidth,
gapBetweenAxisLabelAndBorder = 8,
gapBetweenTickAndTickLabel = 4,
labelAngle = this.config.labelAngle,
tickLength,
tickTextStyle,
}: {
axisLabelHeight?: number;
axisWidth: number;
gapBetweenAxisLabelAndBorder?: number;
gapBetweenTickAndTickLabel?: number;
labelAngle?: number;
tickLength: number;
tickTextStyle: CSSProperties;
}) {
const tickLabels = this.getTickLabels();
const labelDimensions = tickLabels.map((text: string) =>
getTextDimension({
style: tickTextStyle,
text,
}),
);
const { labelOverlap, labelPadding, orient } = this.config;
const maxWidth = Math.max(...labelDimensions.map(d => d.width));
// TODO: Add other strategies: stagger, chop, wrap.
let strategy = labelOverlap;
if (strategy === 'auto') {
// cheap heuristic, can improve
const widthPerTick = axisWidth / tickLabels.length;
if (this.channelEncoder.isY() || maxWidth <= widthPerTick) {
strategy = 'flat';
} else {
strategy = 'rotate';
}
}
if (this.channelEncoder.isX()) {
let labelOffset = 0;
let layout: {
labelAngle: number;
tickTextAnchor?: string;
} = { labelAngle };
if (strategy === 'flat') {
labelOffset = labelDimensions[0].height + labelPadding;
layout = { labelAngle: 0 };
} else if (strategy === 'rotate') {
const labelHeight = Math.ceil(Math.abs(maxWidth * Math.sin((labelAngle * Math.PI) / 180)));
labelOffset = labelHeight + labelPadding;
layout = {
labelAngle,
tickTextAnchor:
(orient === 'top' && labelAngle > 0) || (orient === 'bottom' && labelAngle < 0)
? 'end'
: 'start',
};
}
return {
...layout,
labelOffset,
labelOverlap: strategy,
minMargin: {
[orient]: Math.ceil(
tickLength +
gapBetweenTickAndTickLabel +
labelOffset +
axisLabelHeight +
gapBetweenAxisLabelAndBorder +
8,
),
},
orient,
};
}
const labelOffset = Math.ceil(maxWidth + labelPadding + axisLabelHeight);
return {
labelAngle,
labelOffset,
labelOverlap,
minMargin: {
[orient]:
tickLength + gapBetweenTickAndTickLabel + labelOffset + gapBetweenAxisLabelAndBorder,
},
orient,
};
}
}

View File

@ -1,76 +1,77 @@
import { Value } from 'vega-lite/build/src/fielddef';
import { CategoricalColorScale } from '@superset-ui/color';
import { ScaleOrdinal } from 'd3-scale';
import { TimeFormatter } from '@superset-ui/time-format';
import { NumberFormatter } from '@superset-ui/number-format';
import { extractFormatFromChannelDef } from './parsers/extractFormat';
import extractScale, { ScaleAgent } from './parsers/extractScale';
import extractGetter from './parsers/extractGetter';
import { ChannelOptions, ChannelType } from './types/Channel';
import { PlainObject } from './types/Data';
import {
ChannelDef,
Formatter,
isScaleFieldDef,
isMarkPropFieldDef,
isValueDef,
isFieldDef,
isNonValueDef,
} from './types/FieldDef';
import { PlainObject } from './types/Data';
import extractScale from './parsers/extractScale';
import extractGetter from './parsers/extractGetter';
import extractFormat from './parsers/extractFormat';
import extractAxis, { isXYChannel } from './parsers/extractAxis';
import isEnabled from './utils/isEnabled';
import isDisabled from './utils/isDisabled';
import { ChannelOptions } from './types/Channel';
import identity from './utils/identity';
import AxisAgent from './AxisAgent';
export default class ChannelEncoder<Def extends ChannelDef<Output>, Output extends Value = Value> {
readonly name: string;
readonly name: string | Symbol | number;
readonly type: ChannelType;
readonly definition: Def;
readonly options: ChannelOptions;
readonly axis?: PlainObject;
protected readonly getValue: (datum: PlainObject) => Value;
readonly scale?: ScaleOrdinal<string, Output> | CategoricalColorScale | ((x: any) => Output);
readonly formatter: Formatter;
readonly encodeValue: (value: any) => Output;
protected readonly getValue: (datum: PlainObject) => Value | undefined;
readonly encodeValue: (value: any) => Output | null | undefined;
readonly formatValue: (value: any) => string;
readonly scale?: ScaleAgent<Output>;
readonly axis?: AxisAgent<Def, Output>;
constructor(name: string, definition: Def, options: ChannelOptions = {}) {
constructor({
name,
type,
definition,
options = {},
}: {
name: string | Symbol | number;
type: ChannelType;
definition: Def;
options?: ChannelOptions;
}) {
this.name = name;
this.type = type;
this.definition = definition;
this.options = options;
this.getValue = extractGetter(definition);
this.formatValue = extractFormatFromChannelDef(definition);
const formatter = extractFormat(definition);
this.formatter = formatter;
if (formatter instanceof NumberFormatter) {
this.formatValue = (value: any) => formatter(value);
} else if (formatter instanceof TimeFormatter) {
this.formatValue = (value: any) => formatter(value);
} else {
this.formatValue = formatter;
this.scale = extractScale(this.type, definition, options.namespace);
// Has to extract axis after format and scale
if (
this.isXY() &&
isNonValueDef(this.definition) &&
(('axis' in this.definition && isEnabled(this.definition.axis)) ||
!('axis' in this.definition))
) {
this.axis = new AxisAgent<Def, Output>(this);
}
const scale = extractScale<Output>(definition, options.namespace);
this.scale = scale;
if (typeof scale === 'undefined') {
this.encodeValue = identity;
} else if (scale instanceof CategoricalColorScale) {
this.encodeValue = (value: any) => scale(`${value}`);
} else {
this.encodeValue = (value: any) => scale(value);
}
this.axis = extractAxis(name, definition, this.formatter);
}
get(datum: PlainObject, otherwise?: any) {
const value = this.getValue(datum);
return otherwise !== undefined && (value === null || value === undefined) ? otherwise : value;
this.encodeValue = this.scale ? this.scale.encodeValue : identity;
this.encode = this.encode.bind(this);
this.format = this.format.bind(this);
this.get = this.get.bind(this);
}
encode(datum: PlainObject, otherwise?: Output) {
const output = this.encodeValue(this.get(datum));
const value = this.get(datum);
if (value === null || value === undefined) {
return value;
}
const output = this.encodeValue(value);
return otherwise !== undefined && (output === null || output === undefined)
? otherwise
@ -81,14 +82,22 @@ export default class ChannelEncoder<Def extends ChannelDef<Output>, Output exten
return this.formatValue(this.get(datum));
}
get(datum: PlainObject, otherwise?: any) {
const value = this.getValue(datum);
return otherwise !== undefined && (value === null || value === undefined) ? otherwise : value;
}
getTitle() {
if (isFieldDef(this.definition)) {
return this.definition.title || this.definition.field;
}
return undefined;
}
hasLegend() {
if (isDisabled(this.options.legend)) {
return false;
}
if (isXYChannel(this.name)) {
return false;
}
if (isValueDef(this.definition)) {
if (isDisabled(this.options.legend) || this.isXY() || isValueDef(this.definition)) {
return false;
}
if (isMarkPropFieldDef(this.definition)) {
@ -97,4 +106,16 @@ export default class ChannelEncoder<Def extends ChannelDef<Output>, Output exten
return isScaleFieldDef(this.definition);
}
isX() {
return this.type === 'X' || this.type === 'XBand';
}
isXY() {
return this.type === 'X' || this.type === 'Y' || this.type === 'XBand' || this.type === 'YBand';
}
isY() {
return this.type === 'Y' || this.type === 'YBand';
}
}

View File

@ -1,35 +0,0 @@
import { cloneDeep } from 'lodash';
import { Axis } from 'vega-lite/build/src/axis';
import { ChannelDef, isPositionFieldDef, Formatter } from '../types/FieldDef';
import extractFormat from './extractFormat';
import { PlainObject } from '../types/Data';
export function isXYChannel(channelName: string) {
return channelName === 'x' || channelName === 'y';
}
function isAxis(axis: Axis | null | undefined | false): axis is Axis {
return axis !== false && axis !== null && axis !== undefined;
}
export default function extractAxis(
channelName: string,
definition: ChannelDef,
defaultFormatter: Formatter,
) {
if (isXYChannel(channelName) && isPositionFieldDef(definition)) {
const { type, axis } = definition;
if (isAxis(axis)) {
const parsedAxis: PlainObject = cloneDeep(axis);
const { labels } = parsedAxis;
const { format } = labels;
parsedAxis.format = format
? extractFormat({ field: definition.field, format: axis.format, type })
: defaultFormatter;
return parsedAxis;
}
}
return undefined;
}

View File

@ -1,20 +1,32 @@
import { getNumberFormatter } from '@superset-ui/number-format';
import { getTimeFormatter } from '@superset-ui/time-format';
import { Type } from 'vega-lite/build/src/type';
import { isTypedFieldDef, ChannelDef } from '../types/FieldDef';
export default function extractFormat(definition: ChannelDef) {
const fallbackFormatter = (v: any) => `${v}`;
export function extractFormatFromTypeAndFormat(type: Type, format: string) {
if (type === 'quantitative') {
const formatter = getNumberFormatter(format);
return (value: any) => formatter(value);
} else if (type === 'temporal') {
const formatter = getTimeFormatter(format);
return (value: any) => formatter(value);
}
return fallbackFormatter;
}
export function extractFormatFromChannelDef(definition: ChannelDef) {
if (isTypedFieldDef(definition)) {
const { type } = definition;
const format =
'format' in definition && definition.format !== undefined ? definition.format : '';
switch (type) {
case 'quantitative':
return getNumberFormatter(format);
case 'temporal':
return getTimeFormatter(format);
default:
}
return extractFormatFromTypeAndFormat(type, format);
}
return (v: any) => `${v}`;
return fallbackFormatter;
}

View File

@ -1,38 +1,246 @@
import { CategoricalColorNamespace } from '@superset-ui/color';
import { scaleOrdinal } from 'd3-scale';
import { CategoricalColorNamespace, CategoricalColorScale } from '@superset-ui/color';
import {
ScaleOrdinal,
ScaleLinear,
ScaleLogarithmic,
ScalePower,
ScaleTime,
ScaleQuantile,
ScaleQuantize,
ScaleThreshold,
ScalePoint,
ScaleBand,
scaleLinear,
scaleLog,
scalePow,
scaleSqrt,
scaleTime,
scaleUtc,
scaleQuantile,
scaleQuantize,
scaleThreshold,
scaleOrdinal,
scalePoint,
scaleBand,
} from 'd3-scale';
import { Value } from 'vega-lite/build/src/fielddef';
import isEnabled from '../utils/isEnabled';
import { isScaleFieldDef, ChannelDef, isPositionFieldDef } from '../types/FieldDef';
import { Type } from 'vega-lite/build/src/type';
import { ScaleType } from 'vega-lite/build/src/scale';
import { isNonValueDef, ChannelDef } from '../types/FieldDef';
import isDisabled from '../utils/isDisabled';
import { ChannelType } from '../types/Channel';
import { Scale } from '../types/Scale';
export default function extractScale<Output extends Value = Value>(
definition: ChannelDef<Output>,
namespace?: string,
) {
if (isScaleFieldDef<Output>(definition)) {
const { scale, type } = definition;
if (isEnabled(scale) && !isPositionFieldDef(definition)) {
if (scale) {
const { domain, range, scheme } = scale;
if (type === 'nominal') {
if (scheme) {
return CategoricalColorNamespace.getScale(scheme, namespace);
export interface ScaleAgent<Output extends Value> {
config: Scale<Output>;
setDomain: (newDomain: number[] | string[] | boolean[] | Date[]) => void;
encodeValue: (value: number | string | boolean | null | undefined | Date) => Output;
scale:
| CategoricalColorScale
| ScaleLinear<Output, Output>
| ScaleLogarithmic<Output, Output>
| ScalePower<Output, Output>
| ScaleLogarithmic<Output, Output>
| ScaleTime<Output, Output>
| ScaleQuantile<Output>
| ScaleQuantize<Output>
| ScaleThreshold<number | string | Date, Output>
| ScaleOrdinal<{ toString(): string }, Output>
| ScalePoint<{ toString(): string }>
| ScaleBand<{ toString(): string }>;
}
const scaleFn = scaleOrdinal<any, Output>();
if (domain) {
scaleFn.domain(domain);
}
if (range) {
scaleFn.range(range);
export interface ScaleTypeToD3ScaleType<Output> {
[ScaleType.LINEAR]: ScaleLinear<Output, Output>;
[ScaleType.LOG]: ScaleLogarithmic<Output, Output>;
[ScaleType.POW]: ScalePower<Output, Output>;
[ScaleType.SQRT]: ScalePower<Output, Output>;
[ScaleType.SYMLOG]: ScaleLogarithmic<Output, Output>;
[ScaleType.TIME]: ScaleTime<Output, Output>;
[ScaleType.UTC]: ScaleTime<Output, Output>;
[ScaleType.QUANTILE]: ScaleQuantile<Output>;
[ScaleType.QUANTIZE]: ScaleQuantize<Output>;
[ScaleType.THRESHOLD]: ScaleThreshold<number | string | Date, Output>;
[ScaleType.BIN_ORDINAL]: ScaleOrdinal<{ toString(): string }, Output>;
[ScaleType.ORDINAL]: ScaleOrdinal<{ toString(): string }, Output>;
[ScaleType.POINT]: ScalePoint<{ toString(): string }>;
[ScaleType.BAND]: ScaleBand<{ toString(): string }>;
}
return scaleFn;
// eslint-disable-next-line complexity
export function deriveScaleTypeFromDataTypeAndChannelType(
dataType: Type | undefined,
channelType: ChannelType,
isBinned: boolean = false,
): ScaleType | undefined {
if (typeof dataType === 'undefined') {
return undefined;
} else if (dataType === 'nominal' || dataType === 'ordinal') {
switch (channelType) {
case 'XBand':
case 'YBand':
return ScaleType.POINT;
case 'X':
case 'Y':
case 'Numeric':
return ScaleType.POINT;
case 'Color':
case 'Category':
return ScaleType.ORDINAL;
default:
}
} else if (type === 'nominal') {
return CategoricalColorNamespace.getScale(undefined, namespace);
} else if (dataType === 'quantitative') {
switch (channelType) {
case 'XBand':
case 'YBand':
case 'X':
case 'Y':
case 'Numeric':
return ScaleType.LINEAR;
case 'Color':
return isBinned ? ScaleType.LINEAR : ScaleType.BIN_ORDINAL;
default:
}
} else if (dataType === 'temporal') {
switch (channelType) {
case 'XBand':
case 'YBand':
case 'X':
case 'Y':
case 'Numeric':
return ScaleType.TIME;
case 'Color':
return ScaleType.LINEAR;
default:
}
}
return undefined;
}
// eslint-disable-next-line complexity
function createScaleFromType<Output>(type: ScaleType) {
switch (type) {
case ScaleType.LINEAR:
return scaleLinear<Output>();
case ScaleType.LOG:
return scaleLog<Output>();
case ScaleType.POW:
return scalePow<Output>();
case ScaleType.SQRT:
return scaleSqrt<Output>();
case ScaleType.SYMLOG:
return undefined;
case ScaleType.TIME:
return scaleTime<Output>();
case ScaleType.UTC:
return scaleUtc<Output>();
case ScaleType.QUANTILE:
return scaleQuantile<Output>();
case ScaleType.QUANTIZE:
return scaleQuantize<Output>();
case ScaleType.THRESHOLD:
return scaleThreshold<number | string | Date, Output>();
case ScaleType.BIN_ORDINAL:
return scaleOrdinal<{ toString(): string }, Output>();
case ScaleType.ORDINAL:
return scaleOrdinal<{ toString(): string }, Output>();
case ScaleType.POINT:
return scalePoint<{ toString(): string }>();
case ScaleType.BAND:
return scaleBand<{ toString(): string }>();
default:
return undefined;
}
}
// eslint-disable-next-line complexity
function createScale<Output extends Value>(
channelType: ChannelType,
scaleType: ScaleType,
config: Scale<Output>,
) {
const { namespace } = config;
if (channelType === 'Color') {
const { scheme } = config;
return typeof scheme === 'string' || typeof scheme === 'undefined'
? CategoricalColorNamespace.getScale(scheme, namespace)
: // TODO: fully use SchemeParams
CategoricalColorNamespace.getScale(scheme.name, namespace);
}
const scale = createScaleFromType<Output>(scaleType);
if (typeof scale !== 'undefined') {
if (scale.domain && typeof config.domain !== 'undefined') {
scale.domain(config.domain as any[]);
}
if (scale.range && typeof config.range !== 'undefined') {
scale.range(config.range as any[]);
}
if ('nice' in scale && scale.nice && config.nice !== false) {
scale.nice();
}
if (
'clamp' in scale &&
typeof scale.clamp !== 'undefined' &&
typeof config.clamp !== 'undefined'
) {
scale.clamp(config.clamp);
}
}
return scale;
}
export default function extractScale<Output extends Value>(
channelType: ChannelType,
definition: ChannelDef<Output>,
namespace?: string,
) {
if (isNonValueDef(definition)) {
const scaleConfig =
'scale' in definition && typeof definition.scale !== 'undefined' ? definition.scale : {};
// return if scale is disabled
if (isDisabled(scaleConfig)) {
return undefined;
}
let scaleType = scaleConfig.type;
if (typeof scaleType === 'undefined') {
// If scale type is not defined, try to derive scale type from field type
const dataType = 'type' in definition ? definition.type : undefined;
scaleType = deriveScaleTypeFromDataTypeAndChannelType(dataType, channelType);
// If still do not have scale type, cannot create scale
if (typeof scaleType === 'undefined') {
return undefined;
}
}
const scale = createScale(channelType, scaleType, { namespace, ...scaleConfig });
if (scale) {
const setDomain =
scale instanceof CategoricalColorScale || typeof scale.domain === 'undefined'
? () => {}
: scale.domain;
return {
config: { ...scaleConfig, type: scaleType },
encodeValue: (scale as unknown) as (
value: number | string | boolean | null | undefined | Date,
) => Output,
scale,
setDomain,
};
}
}
// ValueDef does not have scale
return undefined;
}

View File

@ -1,22 +1,38 @@
interface Axis {
title: string;
import { DateTime } from 'vega-lite/build/src/datetime';
import { AxisOrient } from 'vega';
export type LabelOverlapStrategy = 'auto' | 'flat' | 'rotate';
export interface CoreAxis {
format?: string;
labelAngle: number;
labelOverlap: LabelOverlapStrategy;
/** The padding, in pixels, between axis and text labels. */
labelPadding: number;
orient: AxisOrient;
tickCount: number;
format: string;
title?: string;
/** Explicitly set the visible axis tick values. */
values?: string[] | number[] | boolean[] | DateTime[];
}
export type XAxis = Axis & {
orient: 'top' | 'bottom';
labelAngle: number;
labelOverlap: string;
};
export type Axis = Partial<CoreAxis>;
export interface XAxis extends Axis {
orient?: 'top' | 'bottom';
labelAngle?: number;
labelOverlap?: LabelOverlapStrategy;
}
export interface WithXAxis {
axis?: XAxis;
}
export type YAxis = Axis & {
orient: 'left' | 'right';
};
export interface YAxis extends Axis {
orient?: 'left' | 'right';
labelAngle?: 0;
labelOverlap?: 'auto' | 'flat';
}
export interface WithYAxis {
axis?: YAxis;
@ -25,3 +41,7 @@ export interface WithYAxis {
export interface WithAxis {
axis?: XAxis | YAxis;
}
export function isAxis(axis: Axis | null | undefined | false): axis is Axis {
return axis !== false && axis !== null && axis !== undefined;
}

View File

@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export type ObjectWithKeysFromAndValueType<T extends {}, V> = { [key in keyof T]: V };

View File

@ -1,5 +1,44 @@
import { Value } from 'vega-lite/build/src/fielddef';
import { XFieldDef, YFieldDef, ChannelDef, MarkPropChannelDef, TextChannelDef } from './FieldDef';
import { ObjectWithKeysFromAndValueType } from './Base';
// eslint-disable-next-line import/prefer-default-export
export interface ChannelOptions {
namespace?: string;
legend?: boolean;
}
/**
* Define all channel types and mapping to available definition grammar
*/
export interface ChannelTypeToDefMap<Output extends Value = Value>
extends ObjectWithKeysFromAndValueType<ChannelTypeToDefMap<Output>, ChannelDef> {
// position on x-axis
X: XFieldDef<Output>;
// position on y-axis
Y: YFieldDef<Output>;
// position on x-axis but as a range, e.g., bar chart or heat map
XBand: XFieldDef<Output>;
// position on y-axis but as a range, e.g., bar chart or heat map
YBand: YFieldDef<Output>;
// numeric attributes of the mark, e.g., size, opacity
Numeric: MarkPropChannelDef<Output>;
// categorical attributes of the mark, e.g., color, visibility, shape
Category: MarkPropChannelDef<Output>;
// color of the mark
Color: MarkPropChannelDef<Output>;
// plain text, e.g., tooltip, key
Text: TextChannelDef<Output>;
}
export type ChannelType = keyof ChannelTypeToDefMap;
export type ChannelDefFromType<
T extends keyof ChannelTypeToDefMap,
Output extends Value
> = ChannelTypeToDefMap<Output>[T];
export type EncodingFromChannelsAndOutputs<
Channels extends ObjectWithKeysFromAndValueType<Outputs, ChannelType>,
Outputs extends ObjectWithKeysFromAndValueType<Channels, Value>
> = { [key in keyof Channels]: ChannelDefFromType<Channels[key], Outputs[key]> };

View File

@ -14,6 +14,7 @@ export type Formatter = NumberFormatter | TimeFormatter | ((d: any) => string);
export interface FieldDef {
field: string;
format?: string;
title?: string;
}
export interface TypedFieldDef extends FieldDef {
@ -22,8 +23,6 @@ export interface TypedFieldDef extends FieldDef {
export type TextFieldDef = FieldDef;
// PropFieldDef is { field: 'fieldName', scale: xxx }
type ScaleFieldDef<Output extends Value = Value> = TypedFieldDef & WithScale<Output>;
export type MarkPropFieldDef<Output extends Value = Value> = ScaleFieldDef<Output> & WithLegend;
@ -42,19 +41,26 @@ export type MarkPropChannelDef<Output extends Value = Value> =
| MarkPropFieldDef<Output>
| ValueDef<Output>;
export type TextChannelDef = TextFieldDef | ValueDef<string>;
export type TextChannelDef<Output extends Value = Value> = TextFieldDef | ValueDef<Output>;
export type ChannelDef<Output extends Value = Value> =
export type NonValueDef<Output extends Value = Value> =
| XFieldDef<Output>
| YFieldDef<Output>
| MarkPropFieldDef<Output>
| TextFieldDef
| ValueDef<Output>;
| TextFieldDef;
export type ChannelDef<Output extends Value = Value> = NonValueDef<Output> | ValueDef<Output>;
export function isValueDef<Output extends Value>(
channelDef: ChannelDef<Output>,
): channelDef is ValueDef<Output> {
return channelDef && 'value' in channelDef && !!channelDef.value;
return channelDef && 'value' in channelDef;
}
export function isNonValueDef<Output extends Value>(
channelDef: ChannelDef<Output>,
): channelDef is NonValueDef<Output> {
return channelDef && !('value' in channelDef);
}
export function isFieldDef<Output extends Value>(

View File

@ -1,11 +1,16 @@
import { ScaleType } from 'vega-lite/build/src/scale';
import { Value } from 'vega-lite/build/src/fielddef';
import { DateTime } from 'vega-lite/build/src/datetime';
import { SchemeParams, ScaleType } from 'vega-lite/build/src/scale';
export interface Scale<Output extends Value = Value> {
type?: ScaleType;
domain?: any[];
domain?: number[] | string[] | boolean[] | DateTime[];
range?: Output[];
scheme?: string;
clamp?: boolean;
nice?: boolean;
scheme?: string | SchemeParams;
// vega-lite does not have this
namespace?: string;
}
export interface WithScale<Output extends Value = Value> {

View File

@ -0,0 +1,2 @@
/* eslint-disable import/prefer-default-export */
export { default as LineChartPlugin } from './Line/legacy';

View File

@ -1,140 +0,0 @@
/* eslint-disable sort-keys, no-magic-numbers */
import React from 'react';
import collectScalesFromProps from '@data-ui/xy-chart/esm/utils/collectScalesFromProps';
import { XAxis, YAxis } from '@data-ui/xy-chart';
import adjustMargin from './adjustMargin';
import computeXAxisLayout from './computeXAxisLayout';
import computeYAxisLayout from './computeYAxisLayout';
import createTickComponent from './createTickComponent';
import getTickLabels from './getTickLabels';
import ChartFrame from '../components/ChartFrame';
// Additional margin to avoid content hidden behind scroll bar
const OVERFLOW_MARGIN = 8;
export default class XYChartLayout {
constructor(config) {
this.config = config;
const {
width,
height,
minContentWidth = 0,
minContentHeight = 0,
margin,
encoding,
children,
theme,
} = config;
const { x, y } = encoding;
const { xScale, yScale } = collectScalesFromProps({
width,
height,
margin,
xScale: x.scale,
yScale: y.scale,
theme,
children,
});
const { axis: yAxis = {} } = y;
const yLayout = computeYAxisLayout({
orientation: yAxis.orientation,
tickLabels: getTickLabels(yScale, yAxis),
tickLength: theme.yTickStyles.length,
tickTextStyle: theme.yTickStyles.label.right,
});
const secondMargin = adjustMargin(margin, yLayout.minMargin);
const { left, right } = secondMargin;
const { axis: xAxis = {} } = x;
const { orientation: xOrientation = 'bottom' } = xAxis;
const AUTO_ROTATION =
(yLayout.orientation === 'right' && xOrientation === 'bottom') ||
(yLayout.orientation === 'left' && xOrientation === 'top')
? 40
: -40;
const { rotation = AUTO_ROTATION } = xAxis;
const innerWidth = Math.max(width - left - right, minContentWidth);
const xLayout = computeXAxisLayout({
axisWidth: innerWidth,
orientation: xOrientation,
rotation,
tickLabels: getTickLabels(xScale, xAxis),
tickLength: theme.xTickStyles.length,
tickTextStyle: theme.xTickStyles.label.bottom,
});
const finalMargin = adjustMargin(secondMargin, xLayout.minMargin);
const innerHeight = Math.max(height - finalMargin.top - finalMargin.bottom, minContentHeight);
const chartWidth = Math.round(innerWidth + finalMargin.left + finalMargin.right);
const chartHeight = Math.round(innerHeight + finalMargin.top + finalMargin.bottom);
const isOverFlowX = chartWidth > width;
const isOverFlowY = chartHeight > height;
if (isOverFlowX) {
finalMargin.bottom += OVERFLOW_MARGIN;
}
if (isOverFlowY) {
finalMargin.right += OVERFLOW_MARGIN;
}
this.chartWidth = isOverFlowX ? chartWidth + OVERFLOW_MARGIN : chartWidth;
this.chartHeight = isOverFlowY ? chartHeight + OVERFLOW_MARGIN : chartHeight;
this.containerWidth = width;
this.containerHeight = height;
this.margin = finalMargin;
this.xLayout = xLayout;
this.yLayout = yLayout;
}
createChartWithFrame(renderChart) {
return (
<ChartFrame
width={this.containerWidth}
height={this.containerHeight}
containerWidth={this.containerWidth}
contentHeight={this.containerHeight}
renderContent={renderChart}
/>
);
}
createXAxis(props) {
const { axis } = this.config.encoding.x;
return (
<XAxis
label={axis.label}
labelOffset={this.xLayout.labelOffset}
orientation={this.xLayout.orientation}
tickComponent={createTickComponent(this.xLayout)}
tickFormat={axis.tickFormat}
{...props}
/>
);
}
createYAxis(props) {
const { axis } = this.config.encoding.y;
return (
<YAxis
label={axis.label}
labelOffset={this.yLayout.labelOffset}
numTicks={axis.numTicks}
orientation={this.yLayout.orientation}
tickFormat={axis.tickFormat}
{...props}
/>
);
}
}

View File

@ -0,0 +1,182 @@
/* eslint-disable sort-keys, no-magic-numbers */
import React, { ReactNode } from 'react';
import collectScalesFromProps from '@data-ui/xy-chart/esm/utils/collectScalesFromProps';
import { XAxis, YAxis } from '@data-ui/xy-chart';
import { ChartTheme } from '@data-ui/theme';
import { Margin, mergeMargin } from '@superset-ui/dimension';
import { AxisOrient } from 'vega';
import createTickComponent from './createTickComponent';
import ChartFrame from '../components/ChartFrame';
import ChannelEncoder from '../encodeable/ChannelEncoder';
import { XFieldDef, YFieldDef } from '../encodeable/types/FieldDef';
import { PlainObject } from '../encodeable/types/Data';
import { DEFAULT_LABEL_ANGLE } from './constants';
// Additional margin to avoid content hidden behind scroll bar
const OVERFLOW_MARGIN = 8;
interface Input {
width: number;
height: number;
minContentWidth?: number;
minContentHeight?: number;
margin: Margin;
xEncoder: ChannelEncoder<XFieldDef>;
yEncoder: ChannelEncoder<YFieldDef>;
children: ReactNode[];
theme: ChartTheme;
}
export default class XYChartLayout {
chartWidth: number;
chartHeight: number;
containerWidth: number;
containerHeight: number;
margin: Margin;
spec: Input;
xLayout?: {
labelOffset: number;
labelOverlap: string;
labelAngle: number;
tickTextAnchor?: string;
minMargin: Partial<Margin>;
orient: AxisOrient;
};
yLayout?: {
labelOffset: number;
minMargin: Partial<Margin>;
orient: AxisOrient;
};
// eslint-disable-next-line complexity
constructor(spec: Input) {
this.spec = spec;
const {
width,
height,
minContentWidth = 0,
minContentHeight = 0,
margin,
xEncoder,
yEncoder,
children,
theme,
} = spec;
const { xScale, yScale } = collectScalesFromProps({
width,
height,
margin,
xScale: xEncoder.definition.scale || {},
yScale: yEncoder.definition.scale || {},
theme,
children,
});
if (typeof yEncoder.scale !== 'undefined') {
yEncoder.scale.setDomain(yScale.domain());
}
if (typeof yEncoder.axis !== 'undefined') {
this.yLayout = yEncoder.axis.computeLayout({
axisWidth: Math.max(height - margin.top - margin.bottom),
tickLength: theme.yTickStyles.length,
tickTextStyle: theme.yTickStyles.label.right,
});
}
const secondMargin = this.yLayout ? mergeMargin(margin, this.yLayout.minMargin) : margin;
const innerWidth = Math.max(width - secondMargin.left - secondMargin.right, minContentWidth);
if (typeof xEncoder.scale !== 'undefined') {
xEncoder.scale.setDomain(xScale.domain());
}
if (typeof xEncoder.axis !== 'undefined') {
this.xLayout = xEncoder.axis.computeLayout({
axisWidth: innerWidth,
labelAngle: this.recommendXLabelAngle(xEncoder.axis.config.orient as 'top' | 'bottom'),
tickLength: theme.xTickStyles.length,
tickTextStyle: theme.xTickStyles.label.bottom,
});
}
const finalMargin = this.xLayout
? mergeMargin(secondMargin, this.xLayout.minMargin)
: secondMargin;
const innerHeight = Math.max(height - finalMargin.top - finalMargin.bottom, minContentHeight);
const chartWidth = Math.round(innerWidth + finalMargin.left + finalMargin.right);
const chartHeight = Math.round(innerHeight + finalMargin.top + finalMargin.bottom);
const isOverFlowX = chartWidth > width;
const isOverFlowY = chartHeight > height;
if (isOverFlowX) {
finalMargin.bottom += OVERFLOW_MARGIN;
}
if (isOverFlowY) {
finalMargin.right += OVERFLOW_MARGIN;
}
this.chartWidth = isOverFlowX ? chartWidth + OVERFLOW_MARGIN : chartWidth;
this.chartHeight = isOverFlowY ? chartHeight + OVERFLOW_MARGIN : chartHeight;
this.containerWidth = width;
this.containerHeight = height;
this.margin = finalMargin;
}
recommendXLabelAngle(xOrient: 'top' | 'bottom' = 'bottom') {
const { axis } = this.spec.yEncoder;
return !this.yLayout ||
(typeof axis !== 'undefined' &&
((axis.config.orient === 'right' && xOrient === 'bottom') ||
(axis.config.orient === 'left' && xOrient === 'top')))
? DEFAULT_LABEL_ANGLE
: -DEFAULT_LABEL_ANGLE;
}
renderChartWithFrame(renderChart: (input: { width: number; height: number }) => ReactNode) {
return (
<ChartFrame
width={this.containerWidth}
height={this.containerHeight}
contentWidth={this.chartWidth}
contentHeight={this.chartHeight}
renderContent={renderChart}
/>
);
}
renderXAxis(props?: PlainObject) {
const { axis } = this.spec.xEncoder;
return axis && this.xLayout ? (
<XAxis
label={axis.getTitle()}
labelOffset={this.xLayout.labelOffset}
numTicks={axis.config.tickCount}
orientation={axis.config.orient}
tickComponent={createTickComponent(this.xLayout)}
tickFormat={axis.getFormat()}
{...props}
/>
) : null;
}
renderYAxis(props?: PlainObject) {
const { axis } = this.spec.yEncoder;
return axis && this.yLayout ? (
<YAxis
label={axis.getTitle()}
labelOffset={this.yLayout.labelOffset}
numTicks={axis.config.tickCount}
orientation={axis.config.orient}
tickFormat={axis.getFormat()}
{...props}
/>
) : null;
}
}

View File

@ -1,26 +0,0 @@
interface Margin {
top: number;
left: number;
bottom: number;
right: number;
}
export default function adjustMargin(
baseMargin: Partial<Margin> = {},
minMargin: Partial<Margin> = {},
) {
const { top = 0, left = 0, bottom = 0, right = 0 } = baseMargin;
const {
top: minTop = 0,
left: minLeft = 0,
bottom: minBottom = 0,
right: minRight = 0,
} = minMargin;
return {
bottom: Math.max(bottom, minBottom),
left: Math.max(left, minLeft),
right: Math.max(right, minRight),
top: Math.max(top, minTop),
};
}

View File

@ -1,147 +0,0 @@
/* eslint-disable sort-keys, no-magic-numbers */
import React from 'react';
import collectScalesFromProps from '@data-ui/xy-chart/esm/utils/collectScalesFromProps';
import { XAxis, YAxis } from '@data-ui/xy-chart';
import adjustMargin from './adjustMargin';
import computeXAxisLayout from './computeXAxisLayout';
import computeYAxisLayout from './computeYAxisLayout';
import createTickComponent from './createTickComponent';
import getTickLabels from './getTickLabels';
const OVERFLOW_MARGIN = 8;
// {
// width,
// height,
// margin:
// encoding: {
// x: {
// scale:
// axis: {
// labellingStrategy:
// rotation:
// orientation:
// scaleConfig:
// tickFormat:
// tickValues:
// numTicks:
// }
// },
// y: {
// scale:
// axis: {
// tickFormat:
// tickValues:
// numTicks:
// orientation:
// }
// },
// }
// children:
// theme:
// }
export default function computeChartLayout(config) {
const {
width,
height,
minContentWidth = 0,
minContentHeight = 0,
margin,
encoding,
children,
theme,
} = config;
const { x, y } = encoding;
const { xScale, yScale } = collectScalesFromProps({
width,
height,
margin,
xScale: x.scale,
yScale: y.scale,
theme,
children,
});
const { axis: yAxis = {} } = y;
const yLayout = computeYAxisLayout({
orientation: yAxis.orientation,
tickLabels: getTickLabels(yScale, y.axis),
tickLength: theme.xTickStyles.length,
tickTextStyle: theme.yTickStyles.label.right,
});
const secondMargin = adjustMargin(margin, yLayout.minMargin);
const { left, right } = secondMargin;
const innerWidth = Math.max(width - left - right, minContentWidth);
const { axis: xAxis = {} } = x;
const { orientation: xOrientation = 'bottom' } = xAxis;
const AUTO_ROTATION =
(yLayout.orientation === 'right' && xOrientation === 'bottom') ||
(yLayout.orientation === 'left' && xOrientation === 'top')
? 40
: -40;
const { rotation = AUTO_ROTATION } = xAxis;
const xLayout = computeXAxisLayout({
axisWidth: innerWidth,
orientation: xOrientation,
rotation,
tickLabels: getTickLabels(xScale, x.axis),
tickLength: theme.xTickStyles.length,
tickTextStyle: theme.xTickStyles.label.bottom,
});
const finalMargin = adjustMargin(secondMargin, xLayout.minMargin);
const innerHeight = Math.max(height - finalMargin.top - finalMargin.bottom, minContentHeight);
const createXAxis = props => (
<XAxis
label={config.encoding.x.axis.label}
labelOffset={xLayout.labelOffset}
orientation={xLayout.orientation}
tickComponent={createTickComponent(xLayout)}
{...props}
/>
);
const createYAxis = props => (
<YAxis
label={config.encoding.y.axis.label}
labelOffset={yLayout.labelOffset}
numTicks={config.encoding.y.axis.numTicks}
orientation={yLayout.orientation}
tickFormat={config.encoding.y.axis.tickFormat}
{...props}
/>
);
const chartWidth = Math.round(innerWidth + finalMargin.left + finalMargin.right);
const chartHeight = Math.round(innerHeight + finalMargin.top + finalMargin.bottom);
const isOverFlowX = chartWidth > width;
const isOverFlowY = chartHeight > height;
if (isOverFlowX) {
finalMargin.bottom += OVERFLOW_MARGIN;
}
if (isOverFlowY) {
finalMargin.right += OVERFLOW_MARGIN;
}
return {
chartWidth: isOverFlowX ? chartWidth + OVERFLOW_MARGIN : chartWidth,
chartHeight: isOverFlowY ? chartHeight + OVERFLOW_MARGIN : chartHeight,
containerWidth: width,
containerHeight: height,
margin: finalMargin,
x: xLayout,
y: yLayout,
createXAxis,
createYAxis,
};
}

View File

@ -1,75 +0,0 @@
/* eslint-disable no-magic-numbers */
import { getTextDimension } from '@superset-ui/dimension';
export default function computeXAxisLayout({
axisLabelHeight = 20,
axisWidth,
gapBetweenAxisLabelAndBorder = 8,
gapBetweenTickAndTickLabel = 4,
gapBetweenTickLabelsAndAxisLabel = 4,
labellingStrategy = 'auto',
orientation = 'bottom',
rotation = -40,
tickLabels,
tickLength,
tickTextStyle,
}) {
const labelDimensions = tickLabels.map(text =>
getTextDimension({
style: tickTextStyle,
text,
}),
);
const maxWidth = Math.max(...labelDimensions.map(d => d.width));
// cheap heuristic, can improve
const widthPerTick = axisWidth / tickLabels.length;
let finalStrategy;
if (labellingStrategy !== 'auto') {
finalStrategy = labellingStrategy;
} else if (maxWidth <= widthPerTick) {
finalStrategy = 'flat';
} else {
finalStrategy = 'rotate';
}
// TODO: Add other strategies: stagger, chop, wrap.
let layout = { labelOffset: 0 };
if (finalStrategy === 'flat') {
const labelHeight = labelDimensions[0].height;
const labelOffset = labelHeight + gapBetweenTickLabelsAndAxisLabel;
layout = { labelOffset };
} else if (finalStrategy === 'rotate') {
const labelHeight = Math.ceil(Math.abs(maxWidth * Math.sin((rotation * Math.PI) / 180)));
const labelOffset = labelHeight + gapBetweenTickLabelsAndAxisLabel;
const tickTextAnchor =
(orientation === 'top' && rotation > 0) || (orientation === 'bottom' && rotation < 0)
? 'end'
: 'start';
layout = {
labelOffset,
rotation,
tickTextAnchor,
};
}
const { labelOffset } = layout;
return {
...layout,
labellingStrategy: finalStrategy,
minMargin: {
[orientation]: Math.ceil(
tickLength +
gapBetweenTickAndTickLabel +
labelOffset +
axisLabelHeight +
gapBetweenAxisLabelAndBorder +
8,
),
},
orientation,
};
}

View File

@ -1,35 +0,0 @@
/* eslint-disable no-magic-numbers */
import { getTextDimension } from '@superset-ui/dimension';
export default function computeYAxisLayout({
axisLabelHeight = 20,
gapBetweenAxisLabelAndBorder = 8,
gapBetweenTickAndTickLabel = 4,
gapBetweenTickLabelsAndAxisLabel = 4,
orientation = 'left',
tickLabels,
tickLength,
tickTextStyle,
}) {
const labelDimensions = tickLabels.map(text =>
getTextDimension({
style: tickTextStyle,
text,
}),
);
const maxWidth = Math.ceil(Math.max(...labelDimensions.map(d => d.width)));
const labelOffset = Math.ceil(maxWidth + gapBetweenTickLabelsAndAxisLabel + axisLabelHeight);
const margin =
tickLength + gapBetweenTickAndTickLabel + labelOffset + gapBetweenAxisLabelAndBorder;
return {
labelOffset,
minMargin: {
[orientation]: margin,
},
orientation,
};
}

View File

@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const DEFAULT_LABEL_ANGLE = 40;

View File

@ -1,46 +0,0 @@
/* eslint-disable no-magic-numbers */
import React from 'react';
import PropTypes from 'prop-types';
export default function createTickComponent({
labellingStrategy,
orientation = 'bottom',
rotation = 40,
tickTextAnchor = 'start',
}) {
if (labellingStrategy === 'rotate' && rotation !== 0) {
let xOffset = rotation > 0 ? -6 : 6;
if (orientation === 'top') {
xOffset = 0;
}
const yOffset = orientation === 'top' ? -3 : 0;
const propTypes = {
dy: PropTypes.number,
formattedValue: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired,
};
const defaultProps = {
dy: null,
formattedValue: '',
};
const TickComponent = ({ x, y, dy, formattedValue, ...textStyle }) => (
<g transform={`translate(${x + xOffset}, ${y + yOffset})`}>
<text transform={`rotate(${rotation})`} {...textStyle} textAnchor={tickTextAnchor}>
{formattedValue}
</text>
</g>
);
TickComponent.propTypes = propTypes;
TickComponent.defaultProps = defaultProps;
return TickComponent;
}
// This will render the tick as horizontal string.
return null;
}

View File

@ -0,0 +1,48 @@
/* eslint-disable no-magic-numbers */
import React, { CSSProperties } from 'react';
export default function createTickComponent({
labelAngle,
labelOverlap,
orient,
tickTextAnchor = 'start',
}: {
labelAngle: number;
labelOverlap: string;
orient: string;
tickTextAnchor?: string;
}) {
if (labelOverlap === 'rotate' && labelAngle !== 0) {
let xOffset = labelAngle > 0 ? -6 : 6;
if (orient === 'top') {
xOffset = 0;
}
const yOffset = orient === 'top' ? -3 : 0;
const TickComponent = ({
x,
y,
dy,
formattedValue = '',
...textStyle
}: {
x: number;
y: number;
dy?: number;
formattedValue: string;
textStyle: CSSProperties;
}) => (
<g transform={`translate(${x + xOffset}, ${y + yOffset})`}>
<text transform={`rotate(${labelAngle})`} {...textStyle} textAnchor={tickTextAnchor}>
{formattedValue}
</text>
</g>
);
return TickComponent;
}
// This will render the tick as horizontal string.
return null;
}

View File

@ -0,0 +1,30 @@
/* eslint-disable no-magic-numbers */
export default function createTickLabelProps({
labelAngle,
labelOverlap,
orient,
tickTextAnchor = 'start',
}: {
labelAngle: number;
labelOverlap: string;
orient: string;
tickTextAnchor?: string;
}) {
let dx = 0;
let dy = 0;
if (labelOverlap === 'rotate' && labelAngle !== 0) {
dx = labelAngle > 0 ? -6 : 6;
if (orient === 'top') {
dx = 0;
}
dy = orient === 'top' ? -3 : 0;
}
return {
angle: labelAngle,
dx,
dy,
textAnchor: tickTextAnchor,
};
}

View File

@ -1,13 +0,0 @@
/* eslint-disable no-magic-numbers */
import identity from '@vx/axis/build/utils/identity';
export default function getTickLabels(scale, axisConfig) {
const { numTicks = 10, tickValues, tickFormat } = axisConfig;
let values = scale.ticks ? scale.ticks(numTicks) : scale.domain();
if (tickValues) values = tickValues;
let format = scale.tickFormat ? scale.tickFormat() : identity;
if (tickFormat) format = tickFormat;
return values.map(format);
}

View File

@ -1,58 +0,0 @@
import React from 'react';
import { CategoricalColorNamespace } from '@superset-ui/color';
import { LegendOrdinal, LegendItem, LegendLabel } from '@vx/legend';
import { scaleOrdinal } from '@vx/scale';
export default function renderLegend(data, colorEncoding) {
const { accessor, field, scale } = colorEncoding;
const { scheme, namespace } = scale;
const colorFn = CategoricalColorNamespace.getScale(scheme, namespace);
const keySet = new Set();
data.forEach(d => {
keySet.add(accessor ? accessor(d) : d[field]);
});
const keys = [...keySet.values()];
const colorScale = scaleOrdinal({
domain: keys,
range: keys.map(colorFn),
});
return (
<div
style={{
maxHeight: 100,
overflowY: 'hidden',
paddingLeft: 14,
paddingTop: 6,
position: 'relative',
}}
>
<LegendOrdinal scale={colorScale} labelFormat={label => label}>
{labels => (
<div style={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap' }}>
{labels.map(label => {
const size = 8;
return (
<LegendItem
key={`legend-quantile-${label.text}`}
margin="0 5px"
onClick={() => {
alert(`clicked: ${JSON.stringify(label)}`);
}}
>
<svg width={size} height={size} style={{ display: 'inline-block' }}>
<rect fill={label.value} width={size} height={size} />
</svg>
<LegendLabel align="left" margin="0 0 0 4px">
{label.text}
</LegendLabel>
</LegendItem>
);
})}
</div>
)}
</LegendOrdinal>
</div>
);
}

View File

@ -1,7 +1,7 @@
declare module '@data-ui/xy-chart/esm/utils/collectScalesFromProps' {
import { ScaleLinear, ScaleBand, ScaleContinuousNumeric, ScaleDiverging, ScaleIdentity, ScaleLogarithmic, ScaleOrdinal, ScalePoint, ScalePower, ScaleQuantile, ScaleQuantize, ScaleSequential, ScaleThreshold, ScaleTime } from "d3-scale";
import React from "react";
import { ReactNode } from "react";
import { ChartTheme } from "@data-ui/theme";
interface ScaleConfig {
@ -26,7 +26,7 @@ declare module '@data-ui/xy-chart/esm/utils/collectScalesFromProps' {
xScale: ScaleConfig,
yScale: ScaleConfig,
theme: ChartTheme,
children: React.ReactElement[],
children: ReactNode[],
}): {
xScale: Scale;
yScale: Scale;

View File

@ -7,20 +7,25 @@ declare module '@data-ui/xy-chart' {
};
interface XYChartProps {
theme: any;
width: number;
height: number;
ariaLabel: string;
eventTrigger?: any;
margin?: {
top?: number;
right?: number;
bottom?: number;
left?: number;
};
ariaLabel: string;
onMouseMove?: (...args: any[]) => void;
onMouseLeave?: (...args: any[]) => void;
renderTooltip: any;
showYGrid: boolean;
snapTooltipToDataX: boolean;
theme: any;
tooltipData: any;
xScale: any;
yScale: any;
renderTooltip: any;
eventTrigger?: any;
}
export class AreaSeries extends React.PureComponent<Props, {}> {}

View File

@ -1,16 +1,16 @@
/* eslint-disable react/no-multi-comp */
declare module '@vx/legend' {
import React from 'react';
import { ReactNode, ReactElement } from 'react';
export function LegendOrdinal(props: { [key: string]: any }): React.ReactNode;
export function LegendOrdinal(props: { [key: string]: any }): ReactElement;
export function LegendItem(props: { [key: string]: any }): React.ReactNode;
export function LegendItem(props: { [key: string]: any }): ReactElement;
export function LegendLabel(props: {
align: string;
label?: React.ReactNode;
label?: ReactNode;
flex?: string | number;
margin?: string | number;
children?: React.ReactNode;
}): React.ReactNode;
children?: ReactNode;
}): ReactElement;
}

View File

@ -1,10 +1,10 @@
declare module '@vx/responsive' {
import React from 'react';
// eslint-disable-next-line import/prefer-default-export
interface ParentSizeProps {
children: (renderProps: { width: number; height: number }) => React.ReactNode;
}
const ParentSize: React.ComponentType<ParentSizeProps>;
// eslint-disable-next-line import/prefer-default-export
export const ParentSize: React.ComponentType<ParentSizeProps>;
}