[SIP-6] Migrate visualizations to new directory structure (part 2) (#5997)

* migrate MapBox

* migrate bignumber

* migrate timeseries table

* migrate EventFlow

* add default null

* fix linting

* use shortid instead of passing containerId
This commit is contained in:
Krist Wongsuphasawat 2018-10-04 14:45:50 -07:00 committed by Chris Williams
parent a9ef0aeaf5
commit 9f028ccc7b
15 changed files with 340 additions and 317 deletions

View File

@ -1,8 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import shortid from 'shortid';
import { XYChart, AreaSeries, CrossHair, LinearGradient } from '@data-ui/xy-chart';
import { brandColor } from '../../modules/colors';
import { formatDateVerbose } from '../../modules/dates';
import { computeMaxFontSize } from '../../modules/visUtils';
@ -51,30 +50,33 @@ const propTypes = {
bigNumber: PropTypes.number.isRequired,
formatBigNumber: PropTypes.func,
subheader: PropTypes.string,
showTrendline: PropTypes.bool,
showTrendLine: PropTypes.bool,
startYAxisAtZero: PropTypes.bool,
trendlineData: PropTypes.array,
trendLineData: PropTypes.array,
mainColor: PropTypes.string,
gradientId: PropTypes.string,
renderTooltip: PropTypes.func,
};
const defaultProps = {
className: '',
formatBigNumber: identity,
subheader: '',
showTrendline: false,
showTrendLine: false,
startYAxisAtZero: true,
trendlineData: null,
trendLineData: null,
mainColor: brandColor,
gradientId: '',
renderTooltip: renderTooltipFactory(identity),
};
class BigNumberVis extends React.Component {
constructor(props) {
super(props);
this.gradientId = shortid.generate();
}
getClassName() {
const { className, showTrendline } = this.props;
const { className, showTrendLine } = this.props;
const names = `big_number ${className}`;
if (showTrendline) {
if (showTrendLine) {
return names;
}
return `${names} no_trendline`;
@ -148,11 +150,10 @@ class BigNumberVis extends React.Component {
renderTrendline(maxHeight) {
const {
width,
trendlineData,
trendLineData,
mainColor,
subheader,
renderTooltip,
gradientId,
startYAxisAtZero,
} = this.props;
return (
@ -170,13 +171,13 @@ class BigNumberVis extends React.Component {
snapTooltipToDataX
>
<LinearGradient
id={gradientId}
id={this.gradientId}
from={mainColor}
to="#fff"
/>
<AreaSeries
data={trendlineData}
fill={`url(#${gradientId})`}
data={trendLineData}
fill={`url(#${this.gradientId})`}
stroke={mainColor}
/>
<CrossHair
@ -192,10 +193,10 @@ class BigNumberVis extends React.Component {
}
render() {
const { showTrendline, height } = this.props;
const { showTrendLine, height } = this.props;
const className = this.getClassName();
if (showTrendline) {
if (showTrendLine) {
const chartHeight = Math.floor(PROPORTION.TRENDLINE * height);
const allTextHeight = height - chartHeight;
return (

View File

@ -1,86 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import * as color from 'd3-color';
import d3 from 'd3';
import createAdaptor from '../../utils/createAdaptor';
import Component from './BigNumber';
import transformProps from './transformProps';
import BigNumberVis, { renderTooltipFactory } from './BigNumber';
import { d3FormatPreset } from '../../modules/utils';
const TIME_COLUMN = '__timestamp';
function transform(data, formData) {
let bigNumber;
let trendlineData;
const metricName = formData.metric.label || formData.metric;
const compareSuffix = formData.compare_suffix || '';
const compareLag = +formData.compare_lag || 0;
const supportTrendline = formData.viz_type === 'big_number';
const showTrendline = supportTrendline && formData.show_trend_line;
let percentChange = 0;
const subheader = formData.subheader || '';
let formattedSubheader = subheader;
if (supportTrendline) {
const sortedData = [...data].sort((a, b) => a[TIME_COLUMN] - b[TIME_COLUMN]);
bigNumber = sortedData[sortedData.length - 1][metricName];
if (compareLag > 0) {
const compareIndex = sortedData.length - (compareLag + 1);
if (compareIndex >= 0) {
const compareValue = sortedData[compareIndex][metricName];
percentChange = compareValue === 0
? 0 : (bigNumber - compareValue) / Math.abs(compareValue);
const formatPercentChange = d3.format('+.1%');
formattedSubheader = `${formatPercentChange(percentChange)} ${compareSuffix}`;
}
}
trendlineData = showTrendline
? sortedData.map(point => ({ x: point[TIME_COLUMN], y: point[metricName] }))
: null;
} else {
bigNumber = data[0][metricName];
trendlineData = null;
}
let className = '';
if (percentChange > 0) {
className = 'positive';
} else if (percentChange < 0) {
className = 'negative';
}
return {
bigNumber,
trendlineData,
className,
subheader: formattedSubheader,
showTrendline,
};
}
function adaptor(slice, payload) {
const { formData, containerId } = slice;
const transformedData = transform(payload.data, formData);
const startYAxisAtZero = formData.start_y_axis_at_zero;
const formatValue = d3FormatPreset(formData.y_axis_format);
let userColor;
if (formData.color_picker) {
const { r, g, b } = formData.color_picker;
userColor = color.rgb(r, g, b).hex();
}
ReactDOM.render(
<BigNumberVis
width={slice.width()}
height={slice.height()}
formatBigNumber={formatValue}
startYAxisAtZero={startYAxisAtZero}
mainColor={userColor}
gradientId={`big_number_${containerId}`}
renderTooltip={renderTooltipFactory(formatValue)}
{...transformedData}
/>,
document.getElementById(containerId),
);
}
export default adaptor;
export default createAdaptor(Component, transformProps);

View File

@ -1,5 +0,0 @@
import adaptor from './adaptor';
import BigNumber from './BigNumber';
export { BigNumber };
export default adaptor;

View File

@ -0,0 +1,78 @@
import * as color from 'd3-color';
import d3 from 'd3';
import { d3FormatPreset } from '../../modules/utils';
import { renderTooltipFactory } from './BigNumber';
const TIME_COLUMN = '__timestamp';
export default function transformProps(basicChartInput) {
const { formData, payload } = basicChartInput;
const {
colorPicker,
compareLag: compareLagInput,
compareSuffix = '',
metric,
showTrendLine,
startYAxisAtZero,
subheader = '',
vizType,
yAxisFormat,
} = formData;
const { data } = payload;
let mainColor;
if (colorPicker) {
const { r, g, b } = colorPicker;
mainColor = color.rgb(r, g, b).hex();
}
let bigNumber;
let trendLineData;
const metricName = metric.label || metric;
const compareLag = +compareLagInput || 0;
const supportTrendLine = vizType === 'big_number';
const supportAndShowTrendLine = supportTrendLine && showTrendLine;
let percentChange = 0;
let formattedSubheader = subheader;
if (supportTrendLine) {
const sortedData = [...data].sort((a, b) => a[TIME_COLUMN] - b[TIME_COLUMN]);
bigNumber = sortedData[sortedData.length - 1][metricName];
if (compareLag > 0) {
const compareIndex = sortedData.length - (compareLag + 1);
if (compareIndex >= 0) {
const compareValue = sortedData[compareIndex][metricName];
percentChange = compareValue === 0
? 0 : (bigNumber - compareValue) / Math.abs(compareValue);
const formatPercentChange = d3.format('+.1%');
formattedSubheader = `${formatPercentChange(percentChange)} ${compareSuffix}`;
}
}
trendLineData = supportAndShowTrendLine
? sortedData.map(point => ({ x: point[TIME_COLUMN], y: point[metricName] }))
: null;
} else {
bigNumber = data[0][metricName];
trendLineData = null;
}
let className = '';
if (percentChange > 0) {
className = 'positive';
} else if (percentChange < 0) {
className = 'negative';
}
const formatValue = d3FormatPreset(yAxisFormat);
return {
bigNumber,
className,
formatBigNumber: formatValue,
mainColor,
renderTooltip: renderTooltipFactory(formatValue),
showTrendLine: supportAndShowTrendLine,
startYAxisAtZero,
subheader: formattedSubheader,
trendLineData,
};
}

View File

@ -1,62 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import {
App,
withParentSize,
cleanEvents,
TS,
EVENT_NAME,
ENTITY_ID,
} from '@data-ui/event-flow';
import { t } from '../locales';
/*
* This function takes the slice object and json payload as input and renders a
* responsive <EventFlow /> component using the json data.
*/
function renderEventFlow(slice, json) {
const container = document.querySelector(slice.selector);
const hasData = json.data && json.data.length > 0;
// the slice container overflows ~80px in explorer, so we have to correct for this
const isExplorer = (/explore/).test(window.location.pathname);
const ResponsiveVis = withParentSize(({
parentWidth,
parentHeight,
...rest
}) => (
<App
width={parentWidth}
height={parentHeight - (isExplorer ? 80 : 0)}
{...rest}
/>
));
// render the component if we have data, otherwise render a no-data message
let Component;
if (hasData) {
const userKey = json.form_data.entity;
const eventNameKey = json.form_data.all_columns_x;
// map from the Superset form fields to <EventFlow />'s expected data keys
const accessorFunctions = {
[TS]: datum => new Date(datum.__timestamp), // eslint-disable-line no-underscore-dangle
[EVENT_NAME]: datum => datum[eventNameKey],
[ENTITY_ID]: datum => String(datum[userKey]),
};
const dirtyData = json.data;
const cleanData = cleanEvents(dirtyData, accessorFunctions);
const minEventCount = slice.formData.min_leaf_node_event_count;
Component = <ResponsiveVis data={cleanData} initialMinEventCount={minEventCount} />;
} else {
Component = <div>{t('Sorry, there appears to be no data')}</div>;
}
ReactDOM.render(Component, container);
}
module.exports = renderEventFlow;

View File

@ -0,0 +1,52 @@
import React from 'react';
import PropTypes from 'prop-types';
import { App, withParentSize } from '@data-ui/event-flow';
import { t } from '../../locales';
const propTypes = {
className: PropTypes.string,
data: PropTypes.array,
initialMinEventCount: PropTypes.number,
};
const defaultProps = {
className: '',
data: null,
};
function isExplorer() {
return (/explore/).test(window.location.pathname);
}
// The slice container overflows ~80px in explorer,
// so we have to correct for this.
const ResponsiveVis = withParentSize(({
parentWidth,
parentHeight,
...rest
}) => (
<App
width={parentWidth}
height={parentHeight - (isExplorer() ? 80 : 0)}
{...rest}
/>
));
function CustomEventFlow(props) {
const { data, initialMinEventCount } = props;
if (data) {
return (
<ResponsiveVis
data={data}
initialMinEventCount={initialMinEventCount}
/>
);
}
return (
<div>{t('Sorry, there appears to be no data')}</div>
);
}
CustomEventFlow.propTypes = propTypes;
CustomEventFlow.defaultProps = defaultProps;
export default CustomEventFlow;

View File

@ -0,0 +1,5 @@
import createAdaptor from '../../utils/createAdaptor';
import Component from './EventFlow';
import transformProps from './transformProps';
export default createAdaptor(Component, transformProps);

View File

@ -0,0 +1,36 @@
import {
cleanEvents,
TS,
EVENT_NAME,
ENTITY_ID,
} from '@data-ui/event-flow';
export default function transformProps(basicChartInput) {
const { formData, payload } = basicChartInput;
const {
allColumnsX,
entity,
minLeafNodeEventCount,
} = formData;
const { data } = payload;
const hasData = data && data.length > 0;
if (hasData) {
const userKey = entity;
const eventNameKey = allColumnsX;
// map from the Superset form fields to <EventFlow />'s expected data keys
const accessorFunctions = {
[TS]: datum => new Date(datum.__timestamp), // eslint-disable-line no-underscore-dangle
[EVENT_NAME]: datum => datum[eventNameKey],
[ENTITY_ID]: datum => String(datum[userKey]),
};
const cleanData = cleanEvents(data, accessorFunctions);
return {
data: cleanData,
initialMinEventCount: minLeafNodeEventCount,
};
}
return { data: null };
}

View File

@ -1,16 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
import ReactDOM from 'react-dom';
import MapGL from 'react-map-gl';
import Immutable from 'immutable';
import supercluster from 'supercluster';
import ViewportMercator from 'viewport-mercator-project';
import ScatterPlotGlowOverlay from './ScatterPlotGlowOverlay';
import './MapBox.css';
const NOOP = () => {};
const DEFAULT_POINT_RADIUS = 60;
const DEFAULT_MAX_ZOOM = 16;
export const DEFAULT_MAX_ZOOM = 16;
export const DEFAULT_POINT_RADIUS = 60;
const propTypes = {
width: PropTypes.number,
@ -124,86 +122,4 @@ class MapBox extends React.Component {
MapBox.propTypes = propTypes;
MapBox.defaultProps = defaultProps;
function mapbox(slice, payload, setControlValue) {
const { formData, selector } = slice;
const {
hasCustomMetric,
geoJSON,
bounds,
mapboxApiKey,
} = payload.data;
const {
clustering_radius: clusteringRadius,
global_opacity: globalOpacity,
mapbox_color: color,
mapbox_style: mapStyle,
pandas_aggfunc: aggregatorName,
point_radius: pointRadius,
point_radius_unit: pointRadiusUnit,
render_while_dragging: renderWhileDragging,
} = formData;
// Validate mapbox color
const rgb = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/
.exec(color);
if (rgb === null) {
slice.error('Color field must be of form \'rgb(%d, %d, %d)\'');
return;
}
const opts = {
radius: clusteringRadius,
maxZoom: DEFAULT_MAX_ZOOM,
};
if (hasCustomMetric) {
opts.initial = () => ({
sum: 0,
squaredSum: 0,
min: Infinity,
max: -Infinity,
});
opts.map = prop => ({
sum: prop.metric,
squaredSum: Math.pow(prop.metric, 2),
min: prop.metric,
max: prop.metric,
});
opts.reduce = (accu, prop) => {
// Temporarily disable param-reassignment linting to work with supercluster's api
/* eslint-disable no-param-reassign */
accu.sum += prop.sum;
accu.squaredSum += prop.squaredSum;
accu.min = Math.min(accu.min, prop.min);
accu.max = Math.max(accu.max, prop.max);
/* eslint-enable no-param-reassign */
};
}
const clusterer = supercluster(opts);
clusterer.load(geoJSON.features);
ReactDOM.render(
<MapBox
width={slice.width()}
height={slice.height()}
hasCustomMetric={hasCustomMetric}
aggregatorName={aggregatorName}
clusterer={clusterer}
globalOpacity={globalOpacity}
mapStyle={mapStyle}
mapboxApiKey={mapboxApiKey}
onViewportChange={({ latitude, longitude, zoom }) => {
setControlValue('viewport_longitude', longitude);
setControlValue('viewport_latitude', latitude);
setControlValue('viewport_zoom', zoom);
}}
pointRadius={pointRadius === 'Auto' ? DEFAULT_POINT_RADIUS : pointRadius}
pointRadiusUnit={pointRadiusUnit}
renderWhileDragging={renderWhileDragging}
rgb={rgb}
bounds={bounds}
/>,
document.querySelector(selector),
);
}
export default mapbox;
export default MapBox;

View File

@ -0,0 +1,5 @@
import createAdaptor from '../../utils/createAdaptor';
import Component from './MapBox';
import transformProps from './transformProps';
export default createAdaptor(Component, transformProps);

View File

@ -0,0 +1,79 @@
import supercluster from 'supercluster';
import { DEFAULT_POINT_RADIUS, DEFAULT_MAX_ZOOM } from './MapBox';
export default function transformProps(basicChartInput) {
const { formData, onError, payload, setControlValue } = basicChartInput;
const {
bounds,
geoJSON,
hasCustomMetric,
mapboxApiKey,
} = payload.data;
const {
clusteringRadius,
globalOpacity,
mapboxColor,
mapboxStyle,
pandasAggfunc,
pointRadius,
pointRadiusUnit,
renderWhileDragging,
} = formData;
// Validate mapbox color
const rgb = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/
.exec(mapboxColor);
if (rgb === null) {
onError('Color field must be of form \'rgb(%d, %d, %d)\'');
return {};
}
const opts = {
radius: clusteringRadius,
maxZoom: DEFAULT_MAX_ZOOM,
};
if (hasCustomMetric) {
opts.initial = () => ({
sum: 0,
squaredSum: 0,
min: Infinity,
max: -Infinity,
});
opts.map = prop => ({
sum: prop.metric,
squaredSum: Math.pow(prop.metric, 2),
min: prop.metric,
max: prop.metric,
});
opts.reduce = (accu, prop) => {
// Temporarily disable param-reassignment linting to work with supercluster's api
/* eslint-disable no-param-reassign */
accu.sum += prop.sum;
accu.squaredSum += prop.squaredSum;
accu.min = Math.min(accu.min, prop.min);
accu.max = Math.max(accu.max, prop.max);
/* eslint-enable no-param-reassign */
};
}
const clusterer = supercluster(opts);
clusterer.load(geoJSON.features);
return {
aggregatorName: pandasAggfunc,
bounds,
clusterer,
globalOpacity,
hasCustomMetric,
mapboxApiKey,
mapStyle: mapboxStyle,
onViewportChange({ latitude, longitude, zoom }) {
setControlValue('viewport_longitude', longitude);
setControlValue('viewport_latitude', latitude);
setControlValue('viewport_zoom', zoom);
},
pointRadius: pointRadius === 'Auto' ? DEFAULT_POINT_RADIUS : pointRadius,
pointRadiusUnit,
renderWhileDragging,
rgb,
};
}

View File

@ -1,4 +1,3 @@
import ReactDOM from 'react-dom';
import React from 'react';
import PropTypes from 'prop-types';
import d3 from 'd3';
@ -270,58 +269,4 @@ class TimeTable extends React.PureComponent {
TimeTable.propTypes = propTypes;
TimeTable.defaultProps = defaultProps;
function adaptor(slice, payload) {
const { containerId, formData, datasource } = slice;
const {
column_collection: columnConfigs,
groupby,
metrics,
url,
} = formData;
const { records, columns } = payload.data;
const isGroupBy = groupby.length > 0;
// When there is a "group by",
// each row in the table is a database column
// Otherwise,
// each row in the table is a metric
let rows;
if (isGroupBy) {
rows = columns.map(column => (typeof column === 'object')
? column
: { label: column });
} else {
const metricMap = datasource.metrics
.reduce((acc, current) => {
const map = acc;
map[current.metric_name] = current;
return map;
}, {});
rows = metrics.map(metric => (typeof metric === 'object')
? metric
: metricMap[metric]);
}
// TODO: Better parse this from controls instead of mutative value here.
columnConfigs.forEach((column) => {
const c = column;
if (c.timeLag !== undefined && c.timeLag !== null && c.timeLag !== '') {
c.timeLag = parseInt(c.timeLag, 10);
}
});
ReactDOM.render(
<TimeTable
height={slice.height()}
data={records}
columnConfigs={columnConfigs}
rows={rows}
rowType={isGroupBy ? 'column' : 'metric'}
url={url}
/>,
document.getElementById(containerId),
);
}
export default adaptor;
export default TimeTable;

View File

@ -0,0 +1,5 @@
import createAdaptor from '../../utils/createAdaptor';
import Component from './TimeTable';
import transformProps from './transformProps';
export default createAdaptor(Component, transformProps);

View File

@ -0,0 +1,49 @@
export default function transformProps(basicChartInput) {
const { datasource, formData, payload } = basicChartInput;
const {
columnCollection,
groupby,
metrics,
url,
} = formData;
const { records, columns } = payload.data;
const isGroupBy = groupby.length > 0;
// When there is a "group by",
// each row in the table is a database column
// Otherwise,
// each row in the table is a metric
let rows;
if (isGroupBy) {
rows = columns.map(column => (typeof column === 'object')
? column
: { label: column });
} else {
const metricMap = datasource.metrics
.reduce((acc, current) => {
const map = acc;
map[current.metric_name] = current;
return map;
}, {});
rows = metrics.map(metric => (typeof metric === 'object')
? metric
: metricMap[metric]);
}
// TODO: Better parse this from controls instead of mutative value here.
columnCollection.forEach((column) => {
const c = column;
if (c.timeLag !== undefined && c.timeLag !== null && c.timeLag !== '') {
c.timeLag = parseInt(c.timeLag, 10);
}
});
return {
data: records,
columnConfigs: columnCollection,
rows,
rowType: isGroupBy ? 'column' : 'metric',
url,
};
}

View File

@ -65,9 +65,9 @@ const vizMap = {
[VIZ_TYPES.area]: loadNvd3,
[VIZ_TYPES.bar]: loadNvd3,
[VIZ_TYPES.big_number]: () =>
loadVis(import(/* webpackChunkName: 'big_number' */ './BigNumber/index.js')),
loadVis(import(/* webpackChunkName: 'big_number' */ './BigNumber/adaptor.jsx')),
[VIZ_TYPES.big_number_total]: () =>
loadVis(import(/* webpackChunkName: "big_number" */ './BigNumber/index.js')),
loadVis(import(/* webpackChunkName: "big_number" */ './BigNumber/adaptor.jsx')),
[VIZ_TYPES.box_plot]: loadNvd3,
[VIZ_TYPES.bubble]: loadNvd3,
[VIZ_TYPES.bullet]: loadNvd3,
@ -89,7 +89,7 @@ const vizMap = {
[VIZ_TYPES.line_multi]: () =>
loadVis(import(/* webpackChunkName: "line_multi" */ './nvd3/LineMulti.js')),
[VIZ_TYPES.time_pivot]: loadNvd3,
[VIZ_TYPES.mapbox]: () => loadVis(import(/* webpackChunkName: "mapbox" */ './MapBox/MapBox.jsx')),
[VIZ_TYPES.mapbox]: () => loadVis(import(/* webpackChunkName: "mapbox" */ './MapBox/adaptor.jsx')),
[VIZ_TYPES.markup]: () => loadVis(import(/* webpackChunkName: "markup" */ './markup.js')),
[VIZ_TYPES.para]: () =>
loadVis(import(/* webpackChunkName: "parallel_coordinates" */ './ParallelCoordinates/adaptor.jsx')),
@ -101,7 +101,7 @@ const vizMap = {
[VIZ_TYPES.sunburst]: () => loadVis(import(/* webpackChunkName: "sunburst" */ './Sunburst/adaptor.jsx')),
[VIZ_TYPES.table]: () => loadVis(import(/* webpackChunkName: "table" */ './Table/adaptor.jsx')),
[VIZ_TYPES.time_table]: () =>
loadVis(import(/* webpackChunkName: "time_table" */ './TimeTable/TimeTable.jsx')),
loadVis(import(/* webpackChunkName: "time_table" */ './TimeTable/adaptor.jsx')),
[VIZ_TYPES.treemap]: () => loadVis(import(/* webpackChunkName: "treemap" */ './Treemap/adaptor.jsx')),
[VIZ_TYPES.country_map]: () =>
loadVis(import(/* webpackChunkName: "country_map" */ './CountryMap/adaptor.jsx')),
@ -111,7 +111,7 @@ const vizMap = {
loadVis(import(/* webpackChunkName: "world_map" */ './WorldMap/adaptor.jsx')),
[VIZ_TYPES.dual_line]: loadNvd3,
[VIZ_TYPES.event_flow]: () =>
loadVis(import(/* webpackChunkName: "EventFlow" */ './EventFlow.jsx')),
loadVis(import(/* webpackChunkName: "EventFlow" */ './EventFlow/adaptor.jsx')),
[VIZ_TYPES.paired_ttest]: () =>
loadVis(import(/* webpackChunkName: "paired_ttest" */ './PairedTTest/adaptor.jsx')),
[VIZ_TYPES.partition]: () =>