blob: a07ea02f7550adfbfd799e8fb13e0178d42edb11 [file] [log] [blame]
/**
* 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,
styled,
BinaryQueryObjectFilterClause,
useTheme,
} 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
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}
/>
)
);
};
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;