import * as React from 'react'; import { Box, Stack, Text } from '@mantine/core'; import type { ScaleOrdinal } from 'd3v7'; import type { BarSeriesOption } from 'echarts/charts'; import { BlurredOverlay } from '../../components'; import { type ECOption, useChart } from '../../echarts'; import { sanitize } from '../../utils'; import { ErrorMessage } from '../general/ErrorMessage'; import { WarningMessage } from '../general/WarningMessage'; import { NAN_REPLACEMENT, VIS_NEUTRAL_COLOR } from '../general/constants'; import { EAggregateTypes, ICommonVisProps } from '../interfaces'; import { useGetBarChartMouseEvents, useGetBarVisState } from './hooks'; import { EBarDirection, EBarDisplayType, EBarGroupingType, IBarConfig } from './interfaces'; import { AggregatedDataType, BAR_WIDTH, CHART_HEIGHT_MARGIN, DEFAULT_BAR_CHART_HEIGHT, SERIES_ZERO } from './interfaces/internal'; import { numberFormatter } from './utils'; function generateHTMLString({ label, value, color }: { label: string; value: string; color?: string }): string { return `
${label}:
${value}
${color ? `
` : ''}
`; } function EagerSingleEChartsBarChart({ aggregatedData, config, containerWidth, dimensions, globalMax, globalMin, groupColorScale, isGroupedByNumerical, labelsMap, longestLabelWidth, selectedFacetIndex, selectedFacetValue, selectedList, selectedMap, selectionCallback, setConfig, }: Pick, 'config' | 'setConfig' | 'selectedMap' | 'selectedList'> & { aggregatedData: AggregatedDataType; containerWidth: number; dimensions: { height: number; minWidth: number }; globalMax?: number; globalMin?: number; groupColorScale: ScaleOrdinal; isGroupedByNumerical: boolean; labelsMap: Record; longestLabelWidth: number; selectedFacetIndex?: number; selectedFacetValue?: string; selectionCallback: (e: React.MouseEvent, ids: string[]) => void; }) { // NOTE: @dv-usama-ansari: Prepare the base series options for the bar chart. const seriesBase = React.useMemo( () => ({ type: 'bar', blur: { label: { show: false } }, barMaxWidth: BAR_WIDTH, barMinWidth: config?.useResponsiveBarWidth ? 1 : BAR_WIDTH, tooltip: { trigger: 'item', show: true, confine: true, backgroundColor: 'var(--tooltip-bg,var(--mantine-color-gray-9))', borderWidth: 0, borderColor: 'transparent', textStyle: { color: 'var(--tooltip-color,var(--mantine-color-white))', }, axisPointer: { type: 'shadow', }, // NOTE: @dv-usama-ansari: This function is a performance bottleneck. formatter: (params) => { const facetString = selectedFacetValue ? generateHTMLString({ label: `Facet of ${config?.facets?.name}`, value: selectedFacetValue }) : ''; const groupString = (() => { if (config?.group) { const label = `Group of ${config?.group.name}`; const sanitizedSeriesName = sanitize(params.seriesName as string); const name = sanitizedSeriesName === SERIES_ZERO ? config?.group?.id === config?.facets?.id ? (selectedFacetValue as string) : aggregatedData?.groupingsList.length === 1 ? (aggregatedData?.groupingsList[0] as string) : params.name : sanitizedSeriesName; const color = sanitizedSeriesName === NAN_REPLACEMENT ? VIS_NEUTRAL_COLOR : config?.group?.id === config?.facets?.id ? selectedFacetValue === NAN_REPLACEMENT ? VIS_NEUTRAL_COLOR : (groupColorScale?.(selectedFacetValue as string) ?? VIS_NEUTRAL_COLOR) : (groupColorScale?.(name as string) ?? VIS_NEUTRAL_COLOR); if (isGroupedByNumerical) { if (sanitizedSeriesName === NAN_REPLACEMENT) { return generateHTMLString({ label, value: name, color }); } if (!name.includes(' to ')) { return generateHTMLString({ label, value: name, color }); } const [min, max] = (name ?? '0 to 0').split(' to '); if (!Number.isNaN(Number(min)) && !Number.isNaN(Number(max))) { const formattedMin = numberFormatter.format(Number(min)); const formattedMax = numberFormatter.format(Number(max)); return generateHTMLString({ label, value: `${formattedMin} to ${formattedMax}`, color }); } return generateHTMLString({ label, value: params.value as string, color }); } return generateHTMLString({ label, value: name, color }); } return ''; })(); const aggregateString = generateHTMLString({ label: config?.aggregateType === EAggregateTypes.COUNT ? config?.display === EBarDisplayType.NORMALIZED ? `Normalized ${config?.aggregateType}` : config?.aggregateType : `${config?.aggregateType} of ${config?.aggregateColumn?.name}`, value: config?.display === EBarDisplayType.NORMALIZED ? `${params.value}%` : (params.value as string), }); const nonNormalizedString = config?.display === EBarDisplayType.NORMALIZED ? generateHTMLString({ label: config?.aggregateType, value: // NOTE: @dv-usama-ansari: Count is undefined for 100% bars, therefore we need to use a different approach params?.value === 100 ? String( aggregatedData?.categories[params.name]?.groups[Object.keys(aggregatedData?.categories[params.name]?.groups ?? {})[0] as string] ?.total, ) : (String(aggregatedData?.categories[params.name]?.groups[params.seriesName as string]?.total) ?? ''), }) : ''; const categoryString = generateHTMLString({ label: config?.catColumnSelected?.name as string, value: params.name }); const tooltipGrid = `
${categoryString}${nonNormalizedString}${aggregateString}${facetString}${groupString}
`; return tooltipGrid; }, }, label: { show: true, // NOTE: @dv-usama-ansari: This function is a performance bottleneck. formatter: (params) => config?.group && config?.groupType === EBarGroupingType.STACK && config?.display === EBarDisplayType.NORMALIZED ? `${params.value}%` : String(params.value), }, labelLayout: { hideOverlap: true, }, sampling: 'average', large: true, // enable click events on bars -> handled by chartInstance callback in `useChart.mouseEvents.click` triggerEvent: true, clip: false, catColumnSelected: config?.catColumnSelected, group: config?.group, }) as BarSeriesOption, [ config?.useResponsiveBarWidth, config?.catColumnSelected, config?.group, config?.facets?.name, config?.facets?.id, config?.aggregateType, config?.display, config?.aggregateColumn?.name, config?.groupType, selectedFacetValue, aggregatedData?.categories, aggregatedData?.groupingsList, groupColorScale, isGroupedByNumerical, ], ); const hasSelected = React.useMemo(() => (selectedMap ? Object.values(selectedMap).some((selected) => selected) : false), [selectedMap]); const gridLeft = React.useMemo(() => Math.min(longestLabelWidth + 20, containerWidth / 3), [containerWidth, longestLabelWidth]); const { isError, isLoading, isSuccess, visState, yAxisLabel, setYAxisLabel } = useGetBarVisState({ aggregatedData, config: config!, containerWidth, globalMax, globalMin, gridLeft, groupColorScale, hasSelected, isGroupedByNumerical, labelsMap, seriesBase, }); const optionBase = React.useMemo( () => ({ animation: false, tooltip: { trigger: 'axis', axisPointer: { type: 'shadow', }, }, title: [ { text: selectedFacetValue ? `${config?.facets?.name}: ${selectedFacetValue} | ${config?.aggregateType === EAggregateTypes.COUNT ? config?.aggregateType : `${config?.aggregateType} of ${config?.aggregateColumn?.name}`}: ${config?.catColumnSelected?.name}` : `${config?.aggregateType === EAggregateTypes.COUNT ? config?.aggregateType : `${config?.aggregateType} of ${config?.aggregateColumn?.name}`}: ${config?.catColumnSelected?.name}`, triggerEvent: !!config?.facets, left: '50%', textAlign: 'center', name: 'facetTitle', textStyle: { color: '#7F7F7F', fontFamily: 'Roboto, sans-serif', fontSize: '14px', whiteSpace: 'pre', }, }, ], grid: { containLabel: false, left: config?.direction === EBarDirection.HORIZONTAL ? Math.min(gridLeft, containerWidth / 3) : 60, // NOTE: @dv-usama-ansari: Arbitrary fallback value! top: config?.direction === EBarDirection.HORIZONTAL ? 55 : 70, // NOTE: @dv-usama-ansari: Arbitrary value! bottom: config?.direction === EBarDirection.HORIZONTAL ? 55 : 125, // NOTE: @dv-usama-ansari: Arbitrary value! right: 20, // NOTE: @dv-usama-ansari: Arbitrary value! }, legend: { orient: 'horizontal', top: 30, type: 'scroll', icon: 'circle', show: !!config?.group, data: config?.group ? visState.series.map((seriesItem) => ({ name: seriesItem.name, itemStyle: { color: seriesItem.name === NAN_REPLACEMENT ? VIS_NEUTRAL_COLOR : groupColorScale?.(seriesItem.name as string) }, })) : [], // NOTE: @dv-usama-ansari: This function is a performance bottleneck. formatter: (name: string) => { if (isGroupedByNumerical) { if (name === NAN_REPLACEMENT && !name.includes(' to ')) { return name; } const [min, max] = name.split(' to '); const formattedMin = numberFormatter.format(Number(min)); if (max) { const formattedMax = numberFormatter.format(Number(max)); return `${formattedMin} to ${formattedMax}`; } return formattedMin; } return name; }, }, }) as ECOption, [ config?.aggregateColumn?.name, config?.aggregateType, config?.catColumnSelected?.name, config?.direction, config?.facets, config?.group, containerWidth, gridLeft, groupColorScale, isGroupedByNumerical, selectedFacetValue, visState.series, ], ); const options = React.useMemo( () => ({ ...optionBase, series: visState.series, xAxis: visState.xAxis, yAxis: visState.yAxis, }) as ECOption, [optionBase, visState.series, visState.xAxis, visState.yAxis], ); const settings = React.useMemo( () => ({ notMerge: true, }), [], ); // NOTE: @dv-usama-ansari: Create an offscreen canvas to measure the text width. const canvasContext = React.useMemo(() => new OffscreenCanvas(1, 1).getContext('2d'), []); // NOTE: @dv-usama-ansari: Tooltip implementation from: https://codepen.io/plainheart/pen/jOGBrmJ // This element should be used to display tooltips which are not provided by echarts out of the box. const customTooltip = React.useMemo(() => { const dom = document.createElement('div'); dom.id = 'axis-ticks-tooltip'; dom.style.position = 'absolute'; dom.style.backgroundColor = 'rgba(50,50,50)'; dom.style.borderRadius = '4px'; dom.style.color = '#FFFFFF'; dom.style.fontFamily = 'sans-serif'; dom.style.fontSize = '14px'; dom.style.opacity = '0'; dom.style.padding = '4px 8px'; dom.style.transformOrigin = 'bottom'; dom.style.visibility = 'hidden'; dom.style.zIndex = '9999'; dom.style.transition = 'opacity 400ms'; const content = document.createElement('div'); dom.appendChild(content); return { dom, content }; }, []); React.useEffect(() => { if (yAxisLabel) { customTooltip.content.innerText = yAxisLabel ?? ''; } }, [customTooltip.content, yAxisLabel]); const { click } = useGetBarChartMouseEvents({ aggregatedData, config, selectedFacetIndex, selectedFacetValue, selectedList, selectionCallback, setConfig }); const { setRef, instance } = useChart({ options, settings, mouseEvents: { click, mouseover: [ { query: config?.direction === EBarDirection.HORIZONTAL ? { componentType: 'yAxis' } : config?.direction === EBarDirection.VERTICAL ? { componentType: 'xAxis' } : { componentType: 'unknown' }, // No event should be triggered when the direction is not set. handler: (params) => { if (params.targetType === 'axisLabel') { const currLabel = params.event?.target; const fullText = params.value; const displayText = (currLabel as typeof currLabel & { style: { text: string } }).style.text; if (config?.direction === EBarDirection.VERTICAL || fullText !== displayText) { customTooltip.content.innerText = fullText as string; customTooltip.dom.style.opacity = '1'; customTooltip.dom.style.visibility = 'visible'; customTooltip.dom.style.zIndex = '9999'; const topOffset = config?.direction === EBarDirection.HORIZONTAL ? customTooltip.dom.offsetHeight * -1.5 : config?.direction === EBarDirection.VERTICAL ? customTooltip.dom.offsetHeight * -1.25 : 0; const top = (currLabel?.transform[5] ?? 0) + topOffset; const leftOffset = config?.direction === EBarDirection.HORIZONTAL ? customTooltip.dom.offsetWidth * -1 : config?.direction === EBarDirection.VERTICAL ? customTooltip.dom.offsetWidth * -0.5 : 0; const left = Math.max((currLabel?.transform[4] ?? 0) + leftOffset, 0); customTooltip.dom.style.top = `${top}px`; customTooltip.dom.style.left = `${left}px`; } } }, }, { query: { componentType: 'yAxis' }, handler: (params) => { if (params.targetType === 'axisName') { setYAxisLabel(params.name as string); let fullTextWidth = 0; if (canvasContext) { // NOTE: @dv-usama-ansari: This is the default font for ECharts axis labels. canvasContext.font = 'normal normal 12px sans-serif'; canvasContext.textAlign = 'left'; canvasContext.textBaseline = 'top'; // NOTE: @dv-usama-ansari: Measure the width of the full text in an offscreen canvas. fullTextWidth = canvasContext.measureText(yAxisLabel).width; } // NOTE: @dv-usama-ansari: Display the tooltip only if it overflows the chart height. if (fullTextWidth > dimensions.height + CHART_HEIGHT_MARGIN) { customTooltip.content.innerText = yAxisLabel as string; customTooltip.dom.style.opacity = '1'; customTooltip.dom.style.visibility = 'visible'; customTooltip.dom.style.zIndex = '9999'; const top = (dimensions.height + CHART_HEIGHT_MARGIN) / 2; const left = 24; customTooltip.dom.style.top = `${top}px`; customTooltip.dom.style.left = `${left}px`; } } }, }, ], mouseout: [ { query: config?.direction === EBarDirection.HORIZONTAL ? { componentType: 'yAxis' } : config?.direction === EBarDirection.VERTICAL ? { componentType: 'xAxis' } : { componentType: 'unknown' }, // No event should be triggered when the direction is not set. handler: (params) => { if (params.targetType === 'axisLabel') { customTooltip.dom.style.opacity = '0'; customTooltip.dom.style.visibility = 'hidden'; customTooltip.dom.style.zIndex = '-1'; } }, }, { query: { componentType: 'yAxis' }, handler: (params) => { if (params.targetType === 'axisName') { customTooltip.dom.style.opacity = '0'; customTooltip.dom.style.visibility = 'hidden'; customTooltip.dom.style.zIndex = '-1'; } }, }, ], }, }); React.useEffect(() => { if (instance && instance.getDom() && !instance?.getDom()?.querySelector('#axis-tooltip')) { instance.getDom().appendChild(customTooltip.dom); } }, [customTooltip.dom, instance]); return isLoading ? ( ) : isError ? ( {config?.facets && selectedFacetValue ? {selectedFacetValue} : null} Something went wrong setting up your chart. ) : ( isSuccess && (visState.series.length === 0 ? ( config?.facets && selectedFacetValue ? ( {selectedFacetValue} No data available for this facet. ) : ( No data available for this chart. Try a different configuration. ) ) : ( visState?.xAxis && visState?.yAxis && options && dimensions.height > 0 && ( ) )) ); } export const SingleEChartsBarChart = React.memo(EagerSingleEChartsBarChart);