mirror of
https://github.com/apache/superset.git
synced 2026-04-20 08:34:37 +00:00
379 lines
10 KiB
TypeScript
379 lines
10 KiB
TypeScript
/**
|
|
* 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 { PureComponent, MouseEvent } from 'react';
|
|
import {
|
|
t,
|
|
getNumberFormatter,
|
|
getTimeFormatter,
|
|
SMART_DATE_VERBOSE_ID,
|
|
computeMaxFontSize,
|
|
BRAND_COLOR,
|
|
styled,
|
|
BinaryQueryObjectFilterClause,
|
|
} from '@superset-ui/core';
|
|
import Echart from '../components/Echart';
|
|
import { BigNumberVizProps } from './types';
|
|
import { EventHandlers } from '../types';
|
|
|
|
const defaultNumberFormatter = getNumberFormatter();
|
|
|
|
const PROPORTION = {
|
|
// text size: proportion of the chart container sans trendline
|
|
KICKER: 0.1,
|
|
HEADER: 0.3,
|
|
SUBHEADER: 0.125,
|
|
// trendline size: proportion of the whole chart container
|
|
TRENDLINE: 0.3,
|
|
};
|
|
|
|
class BigNumberVis extends PureComponent<BigNumberVizProps> {
|
|
static defaultProps = {
|
|
className: '',
|
|
headerFormatter: defaultNumberFormatter,
|
|
formatTime: getTimeFormatter(SMART_DATE_VERBOSE_ID),
|
|
headerFontSize: PROPORTION.HEADER,
|
|
kickerFontSize: PROPORTION.KICKER,
|
|
mainColor: BRAND_COLOR,
|
|
showTimestamp: false,
|
|
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, showTimestamp } = this.props;
|
|
if (!formatTime || !bigNumberFallback || showTimestamp) return null;
|
|
return (
|
|
<span
|
|
className="alert alert-warning"
|
|
role="alert"
|
|
title={t(
|
|
`Last available value seen on %s`,
|
|
formatTime(bigNumberFallback[0]),
|
|
)}
|
|
>
|
|
{t('Not up to date')}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
renderKicker(maxHeight: number) {
|
|
const { timestamp, showTimestamp, formatTime, width } = this.props;
|
|
if (
|
|
!formatTime ||
|
|
!showTimestamp ||
|
|
typeof timestamp === 'string' ||
|
|
typeof timestamp === 'boolean'
|
|
)
|
|
return null;
|
|
|
|
const text = timestamp === null ? '' : formatTime(timestamp);
|
|
|
|
const container = this.createTemporaryContainer();
|
|
document.body.append(container);
|
|
const fontSize = computeMaxFontSize({
|
|
text,
|
|
maxWidth: width,
|
|
maxHeight,
|
|
className: 'kicker',
|
|
container,
|
|
});
|
|
container.remove();
|
|
|
|
return (
|
|
<div
|
|
className="kicker"
|
|
style={{
|
|
fontSize,
|
|
height: maxHeight,
|
|
}}
|
|
>
|
|
{text}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
renderHeader(maxHeight: number) {
|
|
const { bigNumber, headerFormatter, width, colorThresholdFormatters } =
|
|
this.props;
|
|
// @ts-ignore
|
|
const text = bigNumber === null ? t('No data') : headerFormatter(bigNumber);
|
|
|
|
const hasThresholdColorFormatter =
|
|
Array.isArray(colorThresholdFormatters) &&
|
|
colorThresholdFormatters.length > 0;
|
|
|
|
let numberColor;
|
|
if (hasThresholdColorFormatter) {
|
|
colorThresholdFormatters!.forEach(formatter => {
|
|
const formatterResult = bigNumber
|
|
? formatter.getColorFromValue(bigNumber as number)
|
|
: false;
|
|
if (formatterResult) {
|
|
numberColor = formatterResult;
|
|
}
|
|
});
|
|
} else {
|
|
numberColor = 'black';
|
|
}
|
|
|
|
const container = this.createTemporaryContainer();
|
|
document.body.append(container);
|
|
const fontSize = computeMaxFontSize({
|
|
text,
|
|
maxWidth: width - 8, // Decrease 8px for more precise font size
|
|
maxHeight,
|
|
className: 'header-line',
|
|
container,
|
|
});
|
|
container.remove();
|
|
|
|
const onContextMenu = (e: MouseEvent<HTMLDivElement>) => {
|
|
if (this.props.onContextMenu) {
|
|
e.preventDefault();
|
|
this.props.onContextMenu(e.nativeEvent.clientX, e.nativeEvent.clientY);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="header-line"
|
|
style={{
|
|
fontSize,
|
|
height: maxHeight,
|
|
color: numberColor,
|
|
}}
|
|
onContextMenu={onContextMenu}
|
|
>
|
|
{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,
|
|
});
|
|
container.remove();
|
|
|
|
return (
|
|
<div
|
|
className="subheader-line"
|
|
style={{
|
|
fontSize,
|
|
height: maxHeight,
|
|
}}
|
|
>
|
|
{text}
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
renderTrendline(maxHeight: number) {
|
|
const { width, trendLineData, echartOptions, refs } = this.props;
|
|
|
|
// if can't find any non-null values, no point rendering the trendline
|
|
if (!trendLineData?.some(d => d[1] !== null)) {
|
|
return null;
|
|
}
|
|
|
|
const eventHandlers: EventHandlers = {
|
|
contextmenu: eventParams => {
|
|
if (this.props.onContextMenu) {
|
|
eventParams.event.stop();
|
|
const { data } = eventParams;
|
|
if (data) {
|
|
const pointerEvent = eventParams.event.event;
|
|
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
|
|
drillToDetailFilters.push({
|
|
col: this.props.formData?.granularitySqla,
|
|
grain: this.props.formData?.timeGrainSqla,
|
|
op: '==',
|
|
val: data[0],
|
|
formattedVal: this.props.xValueFormatter?.(data[0]),
|
|
});
|
|
this.props.onContextMenu(
|
|
pointerEvent.clientX,
|
|
pointerEvent.clientY,
|
|
{ drillToDetail: drillToDetailFilters },
|
|
);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
return (
|
|
echartOptions && (
|
|
<Echart
|
|
refs={refs}
|
|
width={Math.floor(width)}
|
|
height={maxHeight}
|
|
echartOptions={echartOptions}
|
|
eventHandlers={eventHandlers}
|
|
/>
|
|
)
|
|
);
|
|
}
|
|
|
|
render() {
|
|
const {
|
|
showTrendLine,
|
|
height,
|
|
kickerFontSize,
|
|
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.renderKicker(
|
|
Math.ceil(
|
|
(kickerFontSize || 0) * (1 - PROPORTION.TRENDLINE) * height,
|
|
),
|
|
)}
|
|
{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.renderFallbackWarning()}
|
|
{this.renderKicker((kickerFontSize || 0) * height)}
|
|
{this.renderHeader(Math.ceil(headerFontSize * height))}
|
|
{this.renderSubheader(Math.ceil(subheaderFontSize * height))}
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
export default styled(BigNumberVis)`
|
|
${({ theme }) => `
|
|
font-family: ${theme.typography.families.sansSerif};
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: flex-start;
|
|
|
|
&.no-trendline .subheader-line {
|
|
padding-bottom: 0.3em;
|
|
}
|
|
|
|
.text-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: flex-start;
|
|
.alert {
|
|
font-size: ${theme.typography.sizes.s};
|
|
margin: -0.5em 0 0.4em;
|
|
line-height: 1;
|
|
padding: ${theme.gridUnit}px;
|
|
border-radius: ${theme.gridUnit}px;
|
|
}
|
|
}
|
|
|
|
.kicker {
|
|
line-height: 1em;
|
|
padding-bottom: 2em;
|
|
}
|
|
|
|
.header-line {
|
|
position: relative;
|
|
line-height: 1em;
|
|
white-space: nowrap;
|
|
span {
|
|
position: absolute;
|
|
bottom: 0;
|
|
}
|
|
}
|
|
|
|
.subheader-line {
|
|
line-height: 1em;
|
|
padding-bottom: 0;
|
|
}
|
|
|
|
&.is-fallback-value {
|
|
.kicker,
|
|
.header-line,
|
|
.subheader-line {
|
|
opacity: ${theme.opacity.mediumHeavy};
|
|
}
|
|
}
|
|
`}
|
|
`;
|