mirror of
https://github.com/apache/superset.git
synced 2026-04-20 16:44:46 +00:00
565 lines
15 KiB
TypeScript
565 lines
15 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 { useState, useEffect, useRef, MouseEvent } from 'react';
|
|
import {
|
|
t,
|
|
getNumberFormatter,
|
|
getTimeFormatter,
|
|
SMART_DATE_VERBOSE_ID,
|
|
computeMaxFontSize,
|
|
BRAND_COLOR,
|
|
BinaryQueryObjectFilterClause,
|
|
} from '@superset-ui/core';
|
|
import { styled, useTheme } from '@apache-superset/core/ui';
|
|
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
|
|
METRIC_NAME: 0.125,
|
|
KICKER: 0.1,
|
|
HEADER: 0.3,
|
|
SUBHEADER: 0.125,
|
|
// trendline size: proportion of the whole chart container
|
|
TRENDLINE: 0.3,
|
|
};
|
|
|
|
function BigNumberVis({
|
|
className = '',
|
|
headerFormatter = defaultNumberFormatter,
|
|
formatTime = getTimeFormatter(SMART_DATE_VERBOSE_ID),
|
|
headerFontSize = PROPORTION.HEADER,
|
|
kickerFontSize = PROPORTION.KICKER,
|
|
metricNameFontSize = PROPORTION.METRIC_NAME,
|
|
showMetricName = true,
|
|
mainColor = BRAND_COLOR,
|
|
showTimestamp = false,
|
|
showTrendLine = false,
|
|
startYAxisAtZero = true,
|
|
subheader = '',
|
|
subheaderFontSize = PROPORTION.SUBHEADER,
|
|
subtitleFontSize = PROPORTION.SUBHEADER,
|
|
timeRangeFixed = false,
|
|
...props
|
|
}: BigNumberVizProps) {
|
|
const theme = useTheme();
|
|
|
|
// Convert state to hooks
|
|
const [elementsRendered, setElementsRendered] = useState(false);
|
|
|
|
// Create refs for each component to measure heights
|
|
const metricNameRef = useRef<HTMLDivElement>(null);
|
|
const kickerRef = useRef<HTMLDivElement>(null);
|
|
const headerRef = useRef<HTMLDivElement>(null);
|
|
const subheaderRef = useRef<HTMLDivElement>(null);
|
|
const subtitleRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Convert componentDidMount
|
|
useEffect(() => {
|
|
// Wait for elements to render and then calculate heights
|
|
const timeout = setTimeout(() => {
|
|
setElementsRendered(true);
|
|
}, 0);
|
|
return () => clearTimeout(timeout);
|
|
}, []);
|
|
|
|
// Convert componentDidUpdate - trigger re-render when height or trendline changes
|
|
useEffect(() => {
|
|
// Re-render when height or showTrendLine changes
|
|
}, [props.height, showTrendLine]);
|
|
|
|
const getClassName = () => {
|
|
const names = `superset-legacy-chart-big-number ${className} ${
|
|
props.bigNumberFallback ? 'is-fallback-value' : ''
|
|
}`;
|
|
if (showTrendLine) return names;
|
|
return `${names} no-trendline`;
|
|
};
|
|
|
|
const createTemporaryContainer = () => {
|
|
const container = document.createElement('div');
|
|
container.className = getClassName();
|
|
container.style.position = 'absolute'; // so it won't disrupt page layout
|
|
container.style.opacity = '0'; // and not visible
|
|
return container;
|
|
};
|
|
|
|
const renderFallbackWarning = () => {
|
|
const { bigNumberFallback } = 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>
|
|
);
|
|
};
|
|
|
|
const renderMetricName = (maxHeight: number) => {
|
|
const { metricName, width } = props;
|
|
if (!showMetricName || !metricName) return null;
|
|
|
|
const text = metricName;
|
|
|
|
const container = createTemporaryContainer();
|
|
document.body.append(container);
|
|
const fontSize = computeMaxFontSize({
|
|
text,
|
|
maxWidth: width,
|
|
maxHeight,
|
|
className: 'metric-name',
|
|
container,
|
|
});
|
|
container.remove();
|
|
|
|
return (
|
|
<div
|
|
ref={metricNameRef}
|
|
className="metric-name"
|
|
style={{
|
|
fontSize,
|
|
height: 'auto',
|
|
}}
|
|
>
|
|
{text}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderKicker = (maxHeight: number) => {
|
|
const { timestamp, width } = props;
|
|
if (
|
|
!formatTime ||
|
|
!showTimestamp ||
|
|
typeof timestamp === 'string' ||
|
|
typeof timestamp === 'bigint' ||
|
|
typeof timestamp === 'boolean'
|
|
)
|
|
return null;
|
|
|
|
const text = timestamp === null ? '' : formatTime(timestamp);
|
|
|
|
const container = createTemporaryContainer();
|
|
document.body.append(container);
|
|
const fontSize = computeMaxFontSize({
|
|
text,
|
|
maxWidth: width,
|
|
maxHeight,
|
|
className: 'kicker',
|
|
container,
|
|
});
|
|
container.remove();
|
|
|
|
return (
|
|
<div
|
|
ref={kickerRef}
|
|
className="kicker"
|
|
style={{
|
|
fontSize,
|
|
height: 'auto',
|
|
}}
|
|
>
|
|
{text}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderHeader = (maxHeight: number) => {
|
|
const { bigNumber, width, colorThresholdFormatters, onContextMenu } = 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 = theme.colorText;
|
|
}
|
|
|
|
const container = createTemporaryContainer();
|
|
document.body.append(container);
|
|
const fontSize = computeMaxFontSize({
|
|
text,
|
|
maxWidth: width * 0.9, // reduced it's max width
|
|
maxHeight,
|
|
className: 'header-line',
|
|
container,
|
|
});
|
|
container.remove();
|
|
|
|
const handleContextMenu = (e: MouseEvent<HTMLDivElement>) => {
|
|
if (onContextMenu) {
|
|
e.preventDefault();
|
|
onContextMenu(e.nativeEvent.clientX, e.nativeEvent.clientY);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={headerRef}
|
|
className="header-line"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
fontSize,
|
|
height: 'auto',
|
|
color: numberColor,
|
|
}}
|
|
onContextMenu={handleContextMenu}
|
|
>
|
|
{text}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const rendermetricComparisonSummary = (maxHeight: number) => {
|
|
const { width } = props;
|
|
let fontSize = 0;
|
|
|
|
const text = subheader;
|
|
|
|
if (text) {
|
|
const container = createTemporaryContainer();
|
|
document.body.append(container);
|
|
try {
|
|
fontSize = computeMaxFontSize({
|
|
text,
|
|
maxWidth: width * 0.9,
|
|
maxHeight,
|
|
className: 'subheader-line',
|
|
container,
|
|
});
|
|
} finally {
|
|
container.remove();
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={subheaderRef}
|
|
className="subheader-line"
|
|
style={{
|
|
fontSize,
|
|
height: maxHeight,
|
|
}}
|
|
>
|
|
{text}
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const renderSubtitle = (maxHeight: number) => {
|
|
const { subtitle, width, bigNumber, bigNumberFallback } = 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 = subtitle;
|
|
if (bigNumber === null) {
|
|
text =
|
|
subtitle || (bigNumberFallback ? NO_DATA : NO_DATA_OR_HASNT_LANDED);
|
|
}
|
|
|
|
if (text) {
|
|
const container = createTemporaryContainer();
|
|
document.body.append(container);
|
|
fontSize = computeMaxFontSize({
|
|
text,
|
|
maxWidth: width * 0.9,
|
|
maxHeight,
|
|
className: 'subtitle-line',
|
|
container,
|
|
});
|
|
container.remove();
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={subtitleRef}
|
|
className="subtitle-line subheader-line"
|
|
style={{
|
|
fontSize: `${fontSize}px`,
|
|
height: maxHeight,
|
|
}}
|
|
>
|
|
{text}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const renderTrendline = (maxHeight: number) => {
|
|
const {
|
|
width,
|
|
trendLineData,
|
|
echartOptions,
|
|
refs,
|
|
onContextMenu,
|
|
formData,
|
|
xValueFormatter,
|
|
} = 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 (onContextMenu) {
|
|
eventParams.event.stop();
|
|
const { data } = eventParams;
|
|
if (data) {
|
|
const pointerEvent = eventParams.event.event;
|
|
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
|
|
drillToDetailFilters.push({
|
|
col: formData?.granularitySqla,
|
|
grain: formData?.timeGrainSqla,
|
|
op: '==',
|
|
val: data[0],
|
|
formattedVal: xValueFormatter?.(data[0]),
|
|
});
|
|
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
|
|
drillToDetail: drillToDetailFilters,
|
|
});
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
return (
|
|
echartOptions && (
|
|
<Echart
|
|
refs={refs}
|
|
width={Math.floor(width)}
|
|
height={maxHeight}
|
|
echartOptions={echartOptions}
|
|
eventHandlers={eventHandlers}
|
|
vizType={formData?.vizType}
|
|
/>
|
|
)
|
|
);
|
|
};
|
|
|
|
const getTotalElementsHeight = () => {
|
|
const marginPerElement = 8; // theme.sizeUnit = 4, so margin-bottom = 8px
|
|
|
|
const refs = [
|
|
metricNameRef,
|
|
kickerRef,
|
|
headerRef,
|
|
subheaderRef,
|
|
subtitleRef,
|
|
];
|
|
|
|
// Filter refs to only those with a current element
|
|
const visibleRefs = refs.filter(ref => ref.current);
|
|
|
|
const totalHeight = visibleRefs.reduce((sum, ref, index) => {
|
|
const height = ref.current?.offsetHeight || 0;
|
|
const margin = index < visibleRefs.length - 1 ? marginPerElement : 0;
|
|
return sum + height + margin;
|
|
}, 0);
|
|
|
|
return totalHeight;
|
|
};
|
|
|
|
const shouldApplyOverflow = (availableHeight: number) => {
|
|
if (!elementsRendered) return false;
|
|
const totalHeight = getTotalElementsHeight();
|
|
return totalHeight > availableHeight;
|
|
};
|
|
|
|
const { height } = props;
|
|
const componentClassName = getClassName();
|
|
|
|
if (showTrendLine) {
|
|
const chartHeight = Math.floor(PROPORTION.TRENDLINE * height);
|
|
const allTextHeight = height - chartHeight;
|
|
const overflow = shouldApplyOverflow(allTextHeight);
|
|
|
|
return (
|
|
<div className={componentClassName}>
|
|
<div
|
|
className="text-container"
|
|
style={{
|
|
height: allTextHeight,
|
|
...(overflow
|
|
? {
|
|
display: 'block',
|
|
boxSizing: 'border-box',
|
|
overflowX: 'hidden',
|
|
overflowY: 'auto',
|
|
width: '100%',
|
|
}
|
|
: {}),
|
|
}}
|
|
>
|
|
{renderFallbackWarning()}
|
|
{renderMetricName(
|
|
Math.ceil(
|
|
(metricNameFontSize || 0) * (1 - PROPORTION.TRENDLINE) * height,
|
|
),
|
|
)}
|
|
{renderKicker(
|
|
Math.ceil(
|
|
(kickerFontSize || 0) * (1 - PROPORTION.TRENDLINE) * height,
|
|
),
|
|
)}
|
|
{renderHeader(
|
|
Math.ceil(headerFontSize * (1 - PROPORTION.TRENDLINE) * height),
|
|
)}
|
|
{rendermetricComparisonSummary(
|
|
Math.ceil(subheaderFontSize * (1 - PROPORTION.TRENDLINE) * height),
|
|
)}
|
|
{renderSubtitle(
|
|
Math.ceil(subtitleFontSize * (1 - PROPORTION.TRENDLINE) * height),
|
|
)}
|
|
</div>
|
|
{renderTrendline(chartHeight)}
|
|
</div>
|
|
);
|
|
}
|
|
const overflow = shouldApplyOverflow(height);
|
|
return (
|
|
<div
|
|
className={componentClassName}
|
|
style={{
|
|
height,
|
|
...(overflow
|
|
? {
|
|
display: 'block',
|
|
boxSizing: 'border-box',
|
|
overflowX: 'hidden',
|
|
overflowY: 'auto',
|
|
width: '100%',
|
|
}
|
|
: {}),
|
|
}}
|
|
>
|
|
<div className="text-container">
|
|
{renderFallbackWarning()}
|
|
{renderMetricName((metricNameFontSize || 0) * height)}
|
|
{renderKicker((kickerFontSize || 0) * height)}
|
|
{renderHeader(Math.ceil(headerFontSize * height))}
|
|
{rendermetricComparisonSummary(Math.ceil(subheaderFontSize * height))}
|
|
{renderSubtitle(Math.ceil(subtitleFontSize * height))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const StyledBigNumberVis = styled(BigNumberVis)`
|
|
${({ theme }) => `
|
|
font-family: ${theme.fontFamily};
|
|
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.fontSizeSM};
|
|
margin: -0.5em 0 0.4em;
|
|
line-height: 1;
|
|
padding: ${theme.sizeUnit}px;
|
|
border-radius: ${theme.borderRadius}px;
|
|
}
|
|
}
|
|
|
|
.kicker {
|
|
line-height: 1em;
|
|
margin-bottom: ${theme.sizeUnit * 2}px;
|
|
}
|
|
|
|
.metric-name {
|
|
line-height: 1em;
|
|
margin-bottom: ${theme.sizeUnit * 2}px;
|
|
}
|
|
|
|
.header-line {
|
|
position: relative;
|
|
line-height: 1em;
|
|
white-space: nowrap;
|
|
margin-bottom:${theme.sizeUnit * 2}px;
|
|
span {
|
|
position: absolute;
|
|
bottom: 0;
|
|
}
|
|
}
|
|
|
|
.subheader-line {
|
|
line-height: 1em;
|
|
margin-bottom: ${theme.sizeUnit * 2}px;
|
|
}
|
|
|
|
.subtitle-line {
|
|
line-height: 1em;
|
|
margin-bottom: ${theme.sizeUnit * 2}px;
|
|
}
|
|
|
|
&.is-fallback-value {
|
|
.kicker,
|
|
.header-line,
|
|
.subheader-line {
|
|
opacity: 60%;
|
|
}
|
|
}
|
|
`}
|
|
`;
|
|
|
|
export default StyledBigNumberVis;
|