feat(big-number): allow fallback to last available value and fix time range for trend lines (#403)

* feat(big-number): add option to align time range

In Superset, when a timeseries query has no data at the beginning period
or end period of the filtered time range, there will not no data records
at those periods, hence the trendline in Big Number chart would not
render those periods. This often causes confusion and misinterpretaiton
in dashboards, especially for those with multiple trendline charts
aligned with each other. They could all be a very smooth line, but
actually showing very different time ranges.

This PR adds an option "alignTimeRange" to apply the filtered time
range on the xAxis. Date periods for empty data will be rendered, but
there will be no connected lines, dots, or tooltips for them.

It's possible to still show tooltips for those periods, but I decided
not to do that as: 1) it makes things much more complicated; 2) I don't
want to confuse zero or nulls with empty data.

* fix(big-number): disable alignRange by default

* refactor(big-number): migrate to Typescript

* fix(big-number): typescript build

* fix(big-number): change tooltip trigger; fix storybook

* fix(big-number): move @types to dependencies

* fix(big-number): move all files to ts

* build(big-number): add @types/d3-color as dependency

* refactor(big-number): remove renderTooltip as prop

* feat(big-number): add timeRangeUseFallback options and some refactor

* fix(big-number): update formatting functions

* fix(big-number): update copy for no data

* fix(big-number): address PR feedbacks

* feat(big-number): replace timeRangeUseFallback with bigNumberFallback

* fix: upgrade @types/react-bootstrap

* build(big-number): move react-bootstrap to dependencies

* refactor(big-number): more coherent types

* feat(big-number): use alert box for fallback values

* build(big-number): remove react-bootstrap

* build: upgrade nimbus and fix versions

Keep running into building errors locally, so upgrade nimbus and
fix all related packages to the working latest version.

* feat(big-number): adjust fallback warning alignment

* build: use a non-fixed version for @types/shortid

* build: revert package versions in main
This commit is contained in:
Jianchao Yang 2020-03-30 12:15:55 -07:00 committed by Yongjie Zhao
parent cb3206b583
commit 80b6e066eb
24 changed files with 790 additions and 482 deletions

View File

@ -29,8 +29,9 @@
},
"dependencies": {
"@data-ui/xy-chart": "^0.0.84",
"@types/d3-color": "^1.2.2",
"@types/shortid": "^0.0.29",
"d3-color": "^1.2.3",
"prop-types": "^15.6.2",
"shortid": "^2.2.14"
},
"peerDependencies": {

View File

@ -20,7 +20,6 @@
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell,
Open Sans, Helvetica Neue, sans-serif;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
@ -31,10 +30,18 @@
}
.superset-legacy-chart-big-number .text-container {
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
}
.superset-legacy-chart-big-number .text-container .alert {
font-size: 11px;
margin: -0.5em 0 0.4em;
line-height: 1;
padding: 2px 4px 3px;
border-radius: 3px;
}
.superset-legacy-chart-big-number .header-line {
@ -46,8 +53,6 @@
.superset-legacy-chart-big-number .header-line span {
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
.superset-legacy-chart-big-number .subheader-line {
@ -55,3 +60,13 @@
padding-bottom: 0;
font-weight: 200;
}
.superset-legacy-chart-big-number.is-fallback-value .header-line,
.superset-legacy-chart-big-number.is-fallback-value .subheader-line {
opacity: 0.5;
}
.superset-data-ui-tooltip {
z-index: 1000;
background: #000;
}

View File

@ -1,259 +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 react/forbid-prop-types */
/* eslint-disable react/jsx-sort-default-props */
/* eslint-disable react/sort-prop-types */
import React from 'react';
import PropTypes from 'prop-types';
import shortid from 'shortid';
import { XYChart, AreaSeries, CrossHair, LinearGradient } from '@data-ui/xy-chart';
import { BRAND_COLOR } from '@superset-ui/color';
import { computeMaxFontSize } from '@superset-ui/dimension';
import './BigNumber.css';
const CHART_MARGIN = {
top: 4,
right: 4,
bottom: 4,
left: 4,
};
const PROPORTION = {
HEADER: 0.3,
SUBHEADER: 0.125,
TRENDLINE: 0.3,
};
export function renderTooltipFactory(formatDate, formatValue) {
function renderTooltip({ datum }) {
const { x: rawDate, y: rawValue } = datum;
const formattedDate = formatDate(rawDate);
const value = formatValue(rawValue);
return (
<div style={{ padding: '4px 8px' }}>
{formattedDate}
<br />
<strong>{value}</strong>
</div>
);
}
renderTooltip.propTypes = {
datum: PropTypes.shape({
x: PropTypes.instanceOf(Date),
y: PropTypes.number,
}).isRequired,
};
return renderTooltip;
}
function identity(x) {
return x;
}
const propTypes = {
className: PropTypes.string,
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
bigNumber: PropTypes.number.isRequired,
formatBigNumber: PropTypes.func,
headerFontSize: PropTypes.number,
subheader: PropTypes.string,
subheaderFontSize: PropTypes.number,
showTrendLine: PropTypes.bool,
startYAxisAtZero: PropTypes.bool,
trendLineData: PropTypes.array,
mainColor: PropTypes.string,
renderTooltip: PropTypes.func,
};
const defaultProps = {
className: '',
formatBigNumber: identity,
headerFontSize: PROPORTION.HEADER,
subheader: '',
subheaderFontSize: PROPORTION.SUBHEADER,
showTrendLine: false,
startYAxisAtZero: true,
trendLineData: null,
mainColor: BRAND_COLOR,
renderTooltip: renderTooltipFactory(identity, identity),
};
class BigNumberVis extends React.PureComponent {
constructor(props) {
super(props);
this.gradientId = shortid.generate();
}
getClassName() {
const { className, showTrendLine } = this.props;
const names = `superset-legacy-chart-big-number ${className}`;
if (showTrendLine) {
return names;
}
return `${names} no-trendline`;
}
createTemporaryContainer() {
const container = document.createElement('div');
container.className = this.getClassName();
container.style.position = 'absolute'; // so it won't disrupt page layout
container.style.opacity = 0; // and not visible
return container;
}
renderHeader(maxHeight) {
const { bigNumber, formatBigNumber, width } = this.props;
const text = bigNumber === null ? 'No data' : formatBigNumber(bigNumber);
const container = this.createTemporaryContainer();
document.body.append(container);
const fontSize = computeMaxFontSize({
text,
maxWidth: Math.floor(width),
maxHeight,
className: 'header-line',
container,
});
document.body.removeChild(container);
return (
<div
className="header-line"
style={{
fontSize,
height: maxHeight,
}}
>
<span>{text}</span>
</div>
);
}
renderSubheader(maxHeight) {
const { bigNumber, subheader, width } = this.props;
let fontSize = 0;
const text =
bigNumber === null
? 'Try applying different filters or ensuring your Datasource contains data'
: subheader;
if (text) {
const container = this.createTemporaryContainer();
document.body.append(container);
fontSize = computeMaxFontSize({
text,
maxWidth: Math.floor(width),
maxHeight,
className: 'subheader-line',
container,
});
document.body.removeChild(container);
}
return (
<div
className="subheader-line"
style={{
fontSize,
height: maxHeight,
}}
>
{text}
</div>
);
}
renderTrendline(maxHeight) {
const {
width,
trendLineData,
mainColor,
subheader,
renderTooltip,
startYAxisAtZero,
} = this.props;
return (
<XYChart
snapTooltipToDataX
ariaLabel={`Big number visualization ${subheader}`}
xScale={{ type: 'timeUtc' }}
yScale={{
type: 'linear',
includeZero: startYAxisAtZero,
}}
width={Math.floor(width)}
height={maxHeight}
margin={CHART_MARGIN}
renderTooltip={renderTooltip}
>
<LinearGradient id={this.gradientId} from={mainColor} to="#fff" />
<AreaSeries data={trendLineData} fill={`url(#${this.gradientId})`} stroke={mainColor} />
<CrossHair
fullHeight
stroke={mainColor}
circleFill={mainColor}
circleStroke="#fff"
showHorizontalLine={false}
strokeDasharray="5,2"
/>
</XYChart>
);
}
render() {
const { showTrendLine, height, headerFontSize, subheaderFontSize } = this.props;
const className = this.getClassName();
if (showTrendLine) {
const chartHeight = Math.floor(PROPORTION.TRENDLINE * height);
const allTextHeight = height - chartHeight;
return (
<div className={className}>
<div className="text-container" style={{ height: allTextHeight }}>
{this.renderHeader(Math.ceil(headerFontSize * (1 - PROPORTION.TRENDLINE) * height))}
{this.renderSubheader(
Math.ceil(subheaderFontSize * (1 - PROPORTION.TRENDLINE) * height),
)}
</div>
{this.renderTrendline(chartHeight)}
</div>
);
}
return (
<div className={className} style={{ height }}>
{this.renderHeader(Math.ceil(headerFontSize * height))}
{this.renderSubheader(Math.ceil(subheaderFontSize * height))}
</div>
);
}
}
BigNumberVis.propTypes = propTypes;
BigNumberVis.defaultProps = defaultProps;
export default BigNumberVis;

View File

@ -0,0 +1,305 @@
/**
* 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 React from 'react';
import shortid from 'shortid';
import { t } from '@superset-ui/translation';
import { getNumberFormatter } from '@superset-ui/number-format';
import { XYChart, AreaSeries, CrossHair, LinearGradient } from '@data-ui/xy-chart';
import { BRAND_COLOR } from '@superset-ui/color';
import { computeMaxFontSize } from '@superset-ui/dimension';
import NumberFormatter from '@superset-ui/number-format/lib/NumberFormatter';
import { smartDateVerboseFormatter } from '@superset-ui/time-format';
import TimeFormatter from '@superset-ui/time-format/lib/TimeFormatter';
import './BigNumber.css';
const defaultNumberFormatter = getNumberFormatter();
const CHART_MARGIN = {
top: 4,
right: 4,
bottom: 4,
left: 4,
};
const PROPORTION = {
// text size: proportion of the chart container sans trendline
HEADER: 0.3,
SUBHEADER: 0.125,
// trendline size: proportion of the whole chart container
TRENDLINE: 0.3,
};
type TimeSeriesDatum = {
x: number; // timestamp as a number
y: number | null;
};
export function renderTooltipFactory(
formatDate = smartDateVerboseFormatter,
formatValue = defaultNumberFormatter,
) {
return function renderTooltip({ datum: { x, y } }: { datum: TimeSeriesDatum }) {
// even though `formatDate` supports timestamp as numbers, we need
// `new Date` to pass type check
return (
<div style={{ padding: '4px 8px' }}>
{formatDate(new Date(x))}
<br />
<strong>{y === null ? t('N/A') : formatValue(y)}</strong>
</div>
);
};
}
type BigNumberVisProps = {
className?: string;
width: number;
height: number;
bigNumber?: number | null;
bigNumberFallback?: TimeSeriesDatum;
formatNumber: NumberFormatter;
formatTime: TimeFormatter;
fromDatetime?: number;
toDatetime?: number;
headerFontSize: number;
subheader: string;
subheaderFontSize: number;
showTrendLine?: boolean;
startYAxisAtZero?: boolean;
timeRangeFixed?: boolean;
trendLineData?: TimeSeriesDatum[];
mainColor: string;
};
class BigNumberVis extends React.PureComponent<BigNumberVisProps, {}> {
private gradientId: string = shortid.generate();
static defaultProps = {
className: '',
formatNumber: (num: number) => String(num),
formatTime: smartDateVerboseFormatter.formatFunc,
headerFontSize: PROPORTION.HEADER,
mainColor: BRAND_COLOR,
showTrendLine: false,
startYAxisAtZero: true,
subheader: '',
subheaderFontSize: PROPORTION.SUBHEADER,
timeRangeFixed: false,
};
getClassName() {
const { className, showTrendLine, bigNumberFallback } = this.props;
const names = `superset-legacy-chart-big-number ${className} ${
bigNumberFallback ? 'is-fallback-value' : ''
}`;
if (showTrendLine) return names;
return `${names} no-trendline`;
}
createTemporaryContainer() {
const container = document.createElement('div');
container.className = this.getClassName();
container.style.position = 'absolute'; // so it won't disrupt page layout
container.style.opacity = '0'; // and not visible
return container;
}
renderFallbackWarning() {
const { bigNumberFallback, formatTime } = this.props;
if (!bigNumberFallback) return null;
return (
<span
className="alert alert-warning"
role="alert"
title={t(`Last available value seen on %s`, formatTime(bigNumberFallback.x))}
>
{t('Not up to date')}
</span>
);
}
renderHeader(maxHeight: number) {
const { bigNumber, formatNumber, width } = this.props;
const text = bigNumber === null ? t('No data') : formatNumber(bigNumber);
const container = this.createTemporaryContainer();
document.body.append(container);
const fontSize = computeMaxFontSize({
text,
maxWidth: width,
maxHeight,
className: 'header-line',
container,
});
document.body.removeChild(container);
return (
<div
className="header-line"
style={{
fontSize,
height: maxHeight,
}}
>
{text}
</div>
);
}
renderSubheader(maxHeight: number) {
const { bigNumber, subheader, width, bigNumberFallback } = this.props;
let fontSize = 0;
const NO_DATA_OR_HASNT_LANDED = t(
'No data after filtering or data is NULL for the latest time record',
);
const NO_DATA = t('Try applying different filters or ensuring your datasource has data');
let text = subheader;
if (bigNumber === null) {
text = bigNumberFallback ? NO_DATA : NO_DATA_OR_HASNT_LANDED;
}
if (text) {
const container = this.createTemporaryContainer();
document.body.append(container);
fontSize = computeMaxFontSize({
text,
maxWidth: width,
maxHeight,
className: 'subheader-line',
container,
});
document.body.removeChild(container);
return (
<div
className="subheader-line"
style={{
fontSize,
height: maxHeight,
}}
>
{text}
</div>
);
}
return null;
}
renderTrendline(maxHeight: number) {
const {
width,
trendLineData,
mainColor,
subheader,
startYAxisAtZero,
formatNumber,
formatTime,
fromDatetime,
timeRangeFixed,
} = this.props;
// if can't find any non-null values, no point rendering the trendline
if (!trendLineData?.some(d => d.y !== null)) {
return null;
}
// Apply a fixed X range if a time range is specified.
//
// XYChart checks the existence of `domain` property and decide whether to
// apply a domain or not, so it must not be `null` or `undefined`
const xScale: { type: string; domain?: number[] } = { type: 'timeUtc' };
const tooltipData = trendLineData && [...trendLineData];
if (tooltipData && timeRangeFixed && fromDatetime) {
const toDatetime = this.props.toDatetime ?? Date.now();
if (tooltipData[0].x > fromDatetime) {
tooltipData.unshift({
x: fromDatetime,
y: null,
});
}
if (tooltipData[tooltipData.length - 1].x < toDatetime) {
tooltipData.push({
x: toDatetime,
y: null,
});
}
xScale.domain = [fromDatetime, toDatetime];
}
return (
<XYChart
snapTooltipToDataX
ariaLabel={`Big number visualization ${subheader}`}
renderTooltip={renderTooltipFactory(formatTime, formatNumber)}
xScale={xScale}
yScale={{
type: 'linear',
includeZero: startYAxisAtZero,
}}
width={Math.floor(width)}
height={maxHeight}
margin={CHART_MARGIN}
eventTrigger="container"
>
<LinearGradient id={this.gradientId} from={mainColor} to="#fff" />
<AreaSeries data={tooltipData} fill={`url(#${this.gradientId})`} stroke={mainColor} />
<CrossHair
fullHeight
stroke={mainColor}
circleFill={mainColor}
circleStroke="#fff"
showHorizontalLine={false}
strokeDasharray="5,2"
/>
</XYChart>
);
}
render() {
const { showTrendLine, height, headerFontSize, subheaderFontSize } = this.props;
const className = this.getClassName();
if (showTrendLine) {
const chartHeight = Math.floor(PROPORTION.TRENDLINE * height);
const allTextHeight = height - chartHeight;
return (
<div className={className}>
<div className="text-container" style={{ height: allTextHeight }}>
{this.renderFallbackWarning()}
{this.renderHeader(Math.ceil(headerFontSize * (1 - PROPORTION.TRENDLINE) * height))}
{this.renderSubheader(
Math.ceil(subheaderFontSize * (1 - PROPORTION.TRENDLINE) * height),
)}
</div>
{this.renderTrendline(chartHeight)}
</div>
);
}
return (
<div className={className} style={{ height }}>
{this.renderHeader(Math.ceil(headerFontSize * height))}
{this.renderSubheader(Math.ceil(subheaderFontSize * height))}
</div>
);
}
}
export default BigNumberVis;

View File

@ -1,149 +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 * as color from 'd3-color';
import { getNumberFormatter, NumberFormats } from '@superset-ui/number-format';
import { getTimeFormatter, TimeFormats, smartDateVerboseFormatter } from '@superset-ui/time-format';
import { renderTooltipFactory } from './BigNumber';
const TIME_COLUMN = '__timestamp';
function getTimeFormatterForGranularity(granularity) {
// Translate time granularity to d3-format
const MINUTE = '%Y-%m-%d %H:%M';
const SUNDAY_BASED_WEEK = '%Y W%U';
const MONDAY_BASED_WEEK = '%Y W%W';
const { DATABASE_DATE, DATABASE_DATETIME } = TimeFormats;
// search for `builtin_time_grains` in incubator-superset/superset/db_engine_specs/base.py
const formats = {
date: DATABASE_DATE,
PT1S: DATABASE_DATETIME, // second
PT1M: MINUTE, // minute
PT5M: MINUTE, // 5 minute
PT10M: MINUTE, // 10 minute
PT15M: MINUTE, // 15 minute
'PT0.5H': MINUTE, // half hour
PT1H: '%Y-%m-%d %H:00', // hour
P1D: DATABASE_DATE, // day
P1W: SUNDAY_BASED_WEEK, // week
P1M: 'smart_date_verbose', // month
'P0.25Y': '%Y Q%q', // quarter
P1Y: '%Y', // year
// d3-time-format weeks does not support weeks start on Sunday
'1969-12-28T00:00:00Z/P1W': SUNDAY_BASED_WEEK, // 'week_start_sunday'
'1969-12-29T00:00:00Z/P1W': MONDAY_BASED_WEEK, // 'week_start_monday'
'P1W/1970-01-03T00:00:00Z': SUNDAY_BASED_WEEK, // 'week_ending_saturday'
'P1W/1970-01-04T00:00:00Z': MONDAY_BASED_WEEK, // 'week_ending_sunday'
};
return granularity in formats
? getTimeFormatter(formats[granularity])
: smartDateVerboseFormatter;
}
export default function transformProps(chartProps) {
const { width, height, formData, queryData } = chartProps;
const {
colorPicker,
compareLag: compareLagInput,
compareSuffix = '',
headerFontSize,
subheaderFontSize,
metric,
showTrendLine,
startYAxisAtZero,
subheader = '',
vizType,
} = formData;
const granularity = formData.timeGrainSqla;
let { yAxisFormat } = formData;
const { data } = queryData;
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.label : metric;
const compareLag = Number(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.length === 0 ? null : 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 = getNumberFormatter(NumberFormats.PERCENT_SIGNED_1_POINT);
formattedSubheader = `${formatPercentChange(percentChange)} ${compareSuffix}`;
}
}
trendLineData = supportAndShowTrendLine
? sortedData.map(point => ({
x: point[TIME_COLUMN],
y: point[metricName],
}))
: null;
} else {
bigNumber = data.length === 0 ? null : data[0][metricName];
trendLineData = null;
}
let className = '';
if (percentChange > 0) {
className = 'positive';
} else if (percentChange < 0) {
className = 'negative';
}
if (!yAxisFormat && chartProps.datasource && chartProps.datasource.metrics) {
chartProps.datasource.metrics.forEach(metricEntry => {
if (metricEntry.metric_name === metric && metricEntry.d3format) {
yAxisFormat = metricEntry.d3format;
}
});
}
const formatDate = getTimeFormatterForGranularity(granularity);
const formatValue = getNumberFormatter(yAxisFormat);
return {
width,
height,
bigNumber,
className,
formatBigNumber: formatValue,
headerFontSize,
subheaderFontSize,
mainColor,
renderTooltip: renderTooltipFactory(formatDate, formatValue),
showTrendLine: supportAndShowTrendLine,
startYAxisAtZero,
subheader: formattedSubheader,
trendLineData,
};
}

View File

@ -0,0 +1,138 @@
/**
* 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 * as color from 'd3-color';
import { getNumberFormatter, NumberFormats } from '@superset-ui/number-format';
import { ChartProps } from '@superset-ui/chart';
import getTimeFormatterForGranularity from '../utils/getTimeFormatterForGranularity';
const TIME_COLUMN = '__timestamp';
const formatPercentChange = getNumberFormatter(NumberFormats.PERCENT_SIGNED_1_POINT);
// we trust both the x (time) and y (big number) to be numeric
type BigNumberDatum = {
[TIME_COLUMN]: number;
[key: string]: number | null;
};
export default function transformProps(chartProps: ChartProps) {
const { width, height, formData, queryData } = chartProps;
const {
colorPicker,
compareLag: compareLagInput,
compareSuffix = '',
headerFontSize,
metric,
showTrendLine,
startYAxisAtZero,
subheader = '',
subheaderFontSize,
timeGrainSqla: granularity,
vizType,
timeRangeFixed = false,
} = formData;
let { yAxisFormat } = formData;
const { data, from_dttm: fromDatetime, to_dttm: toDatetime } = queryData;
const metricName = metric?.label ? metric.label : metric;
const compareLag = Number(compareLagInput) || 0;
const supportTrendLine = vizType === 'big_number';
const supportAndShowTrendLine = supportTrendLine && showTrendLine;
let formattedSubheader = subheader;
let mainColor;
if (colorPicker) {
const { r, g, b } = colorPicker;
mainColor = color.rgb(r, g, b).hex();
}
let trendLineData;
let percentChange = 0;
let bigNumber = data.length === 0 ? null : data[0][metricName];
let bigNumberFallback;
if (data.length > 0) {
const sortedData = (data as BigNumberDatum[])
.map(d => ({ x: d[TIME_COLUMN], y: d[metricName] }))
.sort((a, b) => b.x - a.x); // sort in time descending order
bigNumber = sortedData[0].y;
if (bigNumber === null) {
bigNumberFallback = sortedData.find(d => d.y !== null);
bigNumber = bigNumberFallback ? bigNumberFallback.y : null;
}
if (compareLag > 0) {
const compareIndex = compareLag;
if (compareIndex < sortedData.length) {
const compareValue = sortedData[compareIndex].y;
// compare values must both be non-nulls
if (bigNumber !== null && compareValue !== null && compareValue !== 0) {
percentChange = (bigNumber - compareValue) / Math.abs(compareValue);
formattedSubheader = `${formatPercentChange(percentChange)} ${compareSuffix}`;
}
}
}
if (supportTrendLine) {
// must reverse to ascending order otherwise it confuses tooltip triggers
sortedData.reverse();
trendLineData = supportAndShowTrendLine ? sortedData : undefined;
}
}
let className = '';
if (percentChange > 0) {
className = 'positive';
} else if (percentChange < 0) {
className = 'negative';
}
if (!yAxisFormat && chartProps.datasource && chartProps.datasource.metrics) {
chartProps.datasource.metrics.forEach(
// eslint-disable-next-line camelcase
(metricEntry: { metric_name?: string; d3format: string }) => {
if (metricEntry.metric_name === metric && metricEntry.d3format) {
yAxisFormat = metricEntry.d3format;
}
},
);
}
const formatNumber = getNumberFormatter(yAxisFormat);
const formatTime = getTimeFormatterForGranularity(granularity);
return {
width,
height,
bigNumber,
bigNumberFallback,
className,
formatNumber,
formatTime,
headerFontSize,
subheaderFontSize,
mainColor,
showTrendLine: supportAndShowTrendLine,
startYAxisAtZero,
subheader: formattedSubheader,
trendLineData,
fromDatetime,
toDatetime,
timeRangeFixed,
};
}

View File

@ -0,0 +1,90 @@
/**
* 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 transformProps from '../BigNumber/transformProps';
const formData = {
metric: 'value',
colorPicker: {
r: 0,
g: 122,
b: 135,
a: 1,
},
compareLag: 1,
timeGrainSqla: 'P0.25Y',
compareSuffix: 'over last quarter',
vizType: 'big_number',
yAxisFormat: '.3s',
};
function generateProps(data: object[], extraFormData = {}, extraQueryData = {}) {
return {
width: 200,
height: 500,
annotationData: {},
datasource: {
columnFormats: {},
verboseMap: {},
},
rawDatasource: {},
rawFormData: {},
hooks: {},
initialValues: {},
formData: {
...formData,
...extraFormData,
},
queryData: {
data,
...extraQueryData,
},
};
}
describe('BigNumber', () => {
describe('transformProps()', () => {
const props = generateProps(
[
{
__timestamp: 0,
value: 1,
},
{
__timestamp: 100,
value: null,
},
],
{ showTrendLine: true },
);
const transformed = transformProps(props);
it('timeRangeUseFallback', () => {
// the first item is the last item sorted by __timestamp
const lastDatum = transformed.trendLineData?.pop();
expect(lastDatum?.x).toStrictEqual(100);
expect(lastDatum?.y).toBeNull();
expect(transformed.bigNumber).toStrictEqual(1);
expect(transformed.bigNumberFallback).not.toBeNull();
});
it('formatTime by ganularity', () => {
expect(transformed.formatTime(new Date('2020-01-01'))).toStrictEqual('2020 Q1');
});
});
});

View File

@ -0,0 +1,20 @@
/**
* 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.
*/
declare module '@data-ui/xy-chart';
declare module '*.png';

View File

@ -0,0 +1,70 @@
/**
* 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 { getTimeFormatter, TimeFormats, smartDateVerboseFormatter } from '@superset-ui/time-format';
// Translate time granularity to d3-format
const MINUTE = '%Y-%m-%d %H:%M';
const SUNDAY_BASED_WEEK = '%Y W%U';
const MONDAY_BASED_WEEK = '%Y W%W';
const { DATABASE_DATE, DATABASE_DATETIME } = TimeFormats;
// search for `builtin_time_grains` in incubator-superset/superset/db_engine_specs/base.py
const formats = {
date: DATABASE_DATE,
PT1S: DATABASE_DATETIME, // second
PT1M: MINUTE, // minute
PT5M: MINUTE, // 5 minute
PT10M: MINUTE, // 10 minute
PT15M: MINUTE, // 15 minute
'PT0.5H': MINUTE, // half hour
PT1H: '%Y-%m-%d %H:00', // hour
P1D: DATABASE_DATE, // day
P1W: SUNDAY_BASED_WEEK, // week
P1M: '%Y-%m', // month
'P0.25Y': '%Y Q%q', // quarter
P1Y: '%Y', // year
// d3-time-format weeks does not support weeks start on Sunday
'1969-12-28T00:00:00Z/P1W': SUNDAY_BASED_WEEK, // 'week_start_sunday'
'1969-12-29T00:00:00Z/P1W': MONDAY_BASED_WEEK, // 'week_start_monday'
'P1W/1970-01-03T00:00:00Z': SUNDAY_BASED_WEEK, // 'week_ending_saturday'
'P1W/1970-01-04T00:00:00Z': MONDAY_BASED_WEEK, // 'week_ending_sunday'
};
type TimeGranularity =
| 'date'
| 'PT1S'
| 'PT1M'
| 'PT5M'
| 'PT10M'
| 'PT15M'
| 'PT0.5H'
| 'PT1H'
| 'P1D'
| 'P1W'
| 'P0.25Y'
| 'P1Y'
| '1969-12-28T00:00:00Z/P1W'
| '1969-12-29T00:00:00Z/P1W'
| 'P1W/1970-01-03T00:00:00Z';
export default function getTimeFormatterForGranularity(granularity: TimeGranularity) {
return granularity in formats
? getTimeFormatter(formats[granularity])
: smartDateVerboseFormatter;
}

View File

@ -47,7 +47,7 @@ const EMPTY_EXAMPLES = [
* { storyPath: string, storyName: string, renderStory: fn() => node }
*
*/
const requireContext = require.context('./', /* subdirs= */ true, /index\.jsx?$/);
const requireContext = require.context('./', /* subdirs= */ true, /index\.(js|ts)x?$/);
requireContext.keys().forEach(packageName => {
const packageExport = requireContext(packageName);

View File

@ -1,8 +1,43 @@
/* eslint-disable no-magic-numbers, sort-keys */
/**
* 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 React from 'react';
import { SuperChart } from '@superset-ui/chart';
import testData from './data';
const TIME_COLUMN = '__timestamp';
const formData = {
colorPicker: {
r: 0,
g: 122,
b: 135,
a: 1,
},
compareLag: 1,
compareSuffix: 'over 10Y',
metric: 'sum__SP_POP_TOTL',
showTrendLine: true,
startYAxisAtZero: true,
vizType: 'big_number',
yAxisFormat: '.3s',
};
/**
* Add null values to trendline data
* @param data input data
@ -24,21 +59,7 @@ export default [
width={400}
height={400}
queryData={{ data: testData }}
formData={{
colorPicker: {
r: 0,
g: 122,
b: 135,
a: 1,
},
compareLag: 1,
compareSuffix: 'over 10Y',
metric: 'sum__SP_POP_TOTL',
showTrendLine: true,
startYAxisAtZero: true,
vizType: 'big_number',
yAxisFormat: '.3s',
}}
formData={formData}
/>
),
storyName: 'Basic with Trendline',
@ -51,21 +72,7 @@ export default [
width={400}
height={400}
queryData={{ data: withNulls(testData, 3) }}
formData={{
colorPicker: {
r: 0,
g: 122,
b: 135,
a: 1,
},
compareLag: 1,
compareSuffix: 'over 10Y',
metric: 'sum__SP_POP_TOTL',
showTrendLine: true,
startYAxisAtZero: true,
vizType: 'big_number',
yAxisFormat: '.3s',
}}
formData={formData}
/>
),
storyName: 'Null in the middle',
@ -77,26 +84,39 @@ export default [
chartType="big-number"
width={400}
height={400}
queryData={{ data: testData.slice(0, 9) }}
queryData={{
data: testData.slice(0, 9),
from_dttm: testData[testData.length - 1][TIME_COLUMN],
to_dttm: null,
}}
formData={{
colorPicker: {
r: 0,
g: 122,
b: 135,
a: 1,
},
timeGrainSqla: 'P0.25Y',
compareLag: 1,
compareSuffix: 'over 10Y',
metric: 'sum__SP_POP_TOTL',
showTrendLine: true,
startYAxisAtZero: true,
vizType: 'big_number',
yAxisFormat: '.3s',
...formData,
timeGrainSqla: 'P1Y',
timeRangeFixed: true,
}}
/>
),
storyName: 'Missing head',
storyName: 'Missing range start (fix time range)',
storyPath: 'legacy-|preset-chart-big-number|BigNumberChartPlugin',
},
{
renderStory: () => (
<SuperChart
chartType="big-number"
width={400}
height={400}
queryData={{
data: testData.slice(0, 9),
from_dttm: testData[testData.length - 1][TIME_COLUMN],
to_dttm: testData[0][TIME_COLUMN],
}}
formData={{
...formData,
timeRangeFixed: false,
}}
/>
),
storyName: `Missing range start (don't fix range)`,
storyPath: 'legacy-|preset-chart-big-number|BigNumberChartPlugin',
},
];

View File

@ -1,8 +0,0 @@
import { BigNumberChartPlugin } from '../../../../../superset-ui-legacy-preset-chart-big-number';
import Stories from './Stories.tsx';
new BigNumberChartPlugin().configure({ key: 'big-number' }).register();
export default {
examples: [...Stories],
};

View File

@ -0,0 +1,26 @@
/**
* 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 { BigNumberChartPlugin } from '../../../../../superset-ui-legacy-preset-chart-big-number/src';
import Stories from './Stories';
new BigNumberChartPlugin().configure({ key: 'big-number' }).register();
export default {
examples: [...Stories],
};

View File

@ -1,4 +1,21 @@
/* eslint-disable no-magic-numbers */
/**
* 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 React from 'react';
import { SuperChart } from '@superset-ui/chart';
import data from './data';

View File

@ -1,8 +0,0 @@
import { BigNumberTotalChartPlugin } from '../../../../../superset-ui-legacy-preset-chart-big-number';
import Stories from './Stories';
new BigNumberTotalChartPlugin().configure({ key: 'big-number-total' }).register();
export default {
examples: [...Stories],
};

View File

@ -0,0 +1,26 @@
/**
* 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 { BigNumberTotalChartPlugin } from '../../../../../superset-ui-legacy-preset-chart-big-number/src';
import Stories from './Stories';
new BigNumberTotalChartPlugin().configure({ key: 'big-number-total' }).register();
export default {
examples: [...Stories],
};

View File

@ -1,14 +1,14 @@
/**
* Build only plugins specified by globs
*/
const { spawnSync, spawn } = require('child_process');
const { spawnSync } = require('child_process');
const glob = process.argv[2];
const extraArgs = process.argv.slice(2);
process.env.PATH = `./node_modules/.bin:${process.env.PATH}`;
const run = (cmd) => {
const run = cmd => {
console.log(`>> ${cmd}`);
const [p, ...args] = cmd.split(' ');
const runner = spawnSync;

View File

@ -3489,7 +3489,7 @@
resolved "https://registry.yarnpkg.com/@types/d3-collection/-/d3-collection-1.0.8.tgz#aa9552c570a96e33c132e0fd20e331f64baa9dd5"
integrity sha512-y5lGlazdc0HNO0F3UUX2DPE7OmYvd9Kcym4hXwrJcNUkDaypR5pX+apuMikl9LfTxKItJsY9KYvzBulpCKyvuQ==
"@types/d3-color@*":
"@types/d3-color@*", "@types/d3-color@^1.2.2":
version "1.2.2"
resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-1.2.2.tgz#80cf7cfff7401587b8f89307ba36fe4a576bc7cf"
integrity sha512-6pBxzJ8ZP3dYEQ4YjQ+NVbQaOflfgXq/JbDiS99oLobM2o72uAST4q6yPxHv6FOTCRC/n35ktuo8pvw/S4M7sw==
@ -3954,6 +3954,11 @@
"@types/prop-types" "*"
csstype "^2.2.0"
"@types/shortid@^0.0.29":
version "0.0.29"
resolved "https://registry.yarnpkg.com/@types/shortid/-/shortid-0.0.29.tgz#8093ee0416a6e2bf2aa6338109114b3fbffa0e9b"
integrity sha1-gJPuBBam4r8qpjOBCRFLP7/6Dps=
"@types/sizzle@*":
version "2.3.2"
resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.2.tgz#a811b8c18e2babab7d542b3365887ae2e4d9de47"