UNPKG

@gooddata/react-components

Version:

GoodData.UI - A powerful JavaScript library for building analytical applications

1,385 lines (1,232 loc) • 69 kB
// (C) 2007-2020 GoodData Corporation import { colors2Object, numberFormat } from "@gooddata/numberjs"; import { AFM, Execution, VisualizationObject } from "@gooddata/typings"; import * as invariant from "invariant"; import cloneDeep = require("lodash/cloneDeep"); import compact = require("lodash/compact"); import escape = require("lodash/escape"); import get = require("lodash/get"); import includes = require("lodash/includes"); import isEmpty = require("lodash/isEmpty"); import isEqual = require("lodash/isEqual"); import isUndefined = require("lodash/isUndefined"); import last = require("lodash/last"); import range = require("lodash/range"); import unescape = require("lodash/unescape"); import without = require("lodash/without"); import isNil = require("lodash/isNil"); import * as cx from "classnames"; import Highcharts from "./highcharts/highchartsEntryPoint"; import { MEASURES, SECONDARY_MEASURES, SEGMENT, TERTIARY_MEASURES, VIEW, } from "../../../constants/bucketNames"; import { VisType, VisualizationTypes } from "../../../constants/visualizationTypes"; import { findAttributeInDimension, findMeasureGroupInDimensions, } from "../../../helpers/executionResultHelper"; import { unwrap } from "../../../helpers/utils"; import { isBucketEmpty } from "../../../helpers/mdObjBucketHelper"; import { isSomeHeaderPredicateMatched } from "../../../helpers/headerPredicate"; import { IAxis, IChartConfig, IChartLimits, ISeriesDataItem, ISeriesItem, IPointData, IPatternObject, IChartOptions, ISeriesItemConfig, ICategory, } from "../../../interfaces/Config"; import { IDrillEventIntersectionElementExtended } from "../../../interfaces/DrillEvents"; import { IHeaderPredicate } from "../../../interfaces/HeaderPredicate"; import { IMappingHeader } from "../../../interfaces/MappingHeader"; import { getLighterColor, GRAY, WHITE, TRANSPARENT } from "../utils/color"; import { isAreaChart, isBarChart, isBubbleChart, isBulletChart, isChartSupported, isComboChart, isHeatmap, isOneOfTypes, isScatterPlot, isTreemap, parseValue, stringifyChartTypes, } from "../utils/common"; import { getDrillIntersection } from "../utils/drilldownEventing"; import { canComboChartBeStackedInPercent, getComboChartSeries, getComboChartStackingConfig, } from "./chartOptions/comboChartOptions"; import { getBulletChartSeries } from "./chartOptions/bulletChartOptions"; import { ColorFactory, IColorStrategy } from "./colorFactory"; import { HEATMAP_DATA_POINTS_LIMIT, PIE_CHART_LIMIT, STACK_BY_DIMENSION_INDEX, VIEW_BY_ATTRIBUTES_LIMIT, VIEW_BY_DIMENSION_INDEX, PARENT_ATTRIBUTE_INDEX, PRIMARY_ATTRIBUTE_INDEX, } from "./constants"; import { formatValueForTooltip, getFormattedValueForTooltip } from "./tooltip"; import { DEFAULT_CATEGORIES_LIMIT, DEFAULT_DATA_POINTS_LIMIT, DEFAULT_SERIES_LIMIT, } from "./highcharts/commonConfiguration"; import { getChartProperties } from "./highcharts/helpers"; import { isDataOfReasonableSize } from "./highChartsCreators"; import { NORMAL_STACK, PERCENT_STACK } from "./highcharts/getOptionalStackingConfiguration"; import { getCategoriesForTwoAttributes } from "./chartOptions/extendedStackingChartOptions"; import { setMeasuresToSecondaryAxis } from "../../../helpers/dualAxis"; import { isCssMultiLineTruncationSupported } from "../../../helpers/domUtils"; import omit = require("lodash/omit"); import { getOccupiedMeasureBucketsLocalIdentifiers } from "../../../internal/utils/bucketHelper"; import { IUnwrappedAttributeHeaderWithItems } from "../typings/chart"; const TOOLTIP_PADDING = 10; const isAreaChartStackingEnabled = (options: IChartConfig) => { const { type, stacking, stackMeasures } = options; if (!isAreaChart(type)) { return false; } if (isUndefined(stackMeasures)) { return stacking || isUndefined(stacking); } return stackMeasures; }; // types with only many measures or one measure and one attribute const multiMeasuresAlternatingTypes = [ VisualizationTypes.PIE, VisualizationTypes.DONUT, VisualizationTypes.FUNNEL, VisualizationTypes.TREEMAP, ]; const unsupportedNegativeValuesTypes = [ VisualizationTypes.PIE, VisualizationTypes.DONUT, VisualizationTypes.FUNNEL, VisualizationTypes.TREEMAP, ]; // charts sorted by default by measure value const sortedByMeasureTypes = [VisualizationTypes.PIE, VisualizationTypes.DONUT, VisualizationTypes.FUNNEL]; const unsupportedStackingTypes = [ VisualizationTypes.LINE, VisualizationTypes.AREA, VisualizationTypes.SCATTER, VisualizationTypes.BUBBLE, ]; const nullColor: IPatternObject = { pattern: { path: { d: "M 10 0 L 0 10 M 9 11 L 11 9 M 4 11 L 11 4 M -1 1 L 1 -1 M -1 6 L 6 -1", stroke: GRAY, strokeWidth: 1, fill: WHITE, }, width: 10, height: 10, }, }; export const supportedDualAxesChartTypes = [ VisualizationTypes.COLUMN, VisualizationTypes.BAR, VisualizationTypes.BULLET, VisualizationTypes.LINE, VisualizationTypes.AREA, VisualizationTypes.COMBO, VisualizationTypes.COMBO2, ]; export const supportedTooltipFollowPointerChartTypes = [ VisualizationTypes.COLUMN, VisualizationTypes.BAR, VisualizationTypes.BULLET, VisualizationTypes.COMBO, VisualizationTypes.COMBO2, ]; export const supportedStackingAttributesChartTypes = [ VisualizationTypes.COLUMN, VisualizationTypes.BAR, VisualizationTypes.BULLET, VisualizationTypes.AREA, VisualizationTypes.COMBO, VisualizationTypes.COMBO2, ]; export interface IValidationResult { dataTooLarge: boolean; hasNegativeValue: boolean; } export type ITooltipFactory = ( point: IPointData, maxTooltipContentWidth: number, percentageValue?: number, ) => string; export function isNegativeValueIncluded(series: ISeriesItem[]) { return series.some((seriesItem: ISeriesItem) => (seriesItem.data || []).some(({ y, value }: ISeriesDataItem) => y < 0 || value < 0), ); } function getChartLimits(type: string): IChartLimits { switch (type) { case VisualizationTypes.SCATTER: return { series: DEFAULT_SERIES_LIMIT, categories: DEFAULT_SERIES_LIMIT, }; case VisualizationTypes.PIE: case VisualizationTypes.DONUT: case VisualizationTypes.FUNNEL: return { series: 1, categories: PIE_CHART_LIMIT, }; case VisualizationTypes.TREEMAP: return { series: DEFAULT_SERIES_LIMIT, categories: DEFAULT_DATA_POINTS_LIMIT, dataPoints: DEFAULT_DATA_POINTS_LIMIT, }; case VisualizationTypes.HEATMAP: return { series: DEFAULT_SERIES_LIMIT, categories: DEFAULT_CATEGORIES_LIMIT, dataPoints: HEATMAP_DATA_POINTS_LIMIT, }; default: return { series: DEFAULT_SERIES_LIMIT, categories: DEFAULT_CATEGORIES_LIMIT, }; } } export function cannotShowNegativeValues(type: string) { return isOneOfTypes(type, unsupportedNegativeValuesTypes); } function getTreemapDataForValidation(data: any) { // filter out root nodes return { ...data, series: data.series.map((serie: any) => ({ ...serie, data: serie.data.filter((dataItem: any) => dataItem.id === undefined), })), }; } export function validateData(limits: IChartLimits, chartOptions: IChartOptions): IValidationResult { const { type, isViewByTwoAttributes } = chartOptions; const finalLimits = limits || getChartLimits(type); let dataToValidate = chartOptions.data; if (isTreemap(type)) { dataToValidate = getTreemapDataForValidation(chartOptions.data); } return { dataTooLarge: !isDataOfReasonableSize(dataToValidate, finalLimits, isViewByTwoAttributes), hasNegativeValue: cannotShowNegativeValues(type) && isNegativeValueIncluded(chartOptions.data.series), }; } export function isDerivedMeasure(measureItem: Execution.IMeasureHeaderItem, afm: AFM.IAfm) { return afm.measures.some((measure: AFM.IMeasure) => { const measureDefinition = get(measure, "definition.popMeasure") || get(measure, "definition.previousPeriodMeasure"); const derivedMeasureIdentifier = measureDefinition ? measure.localIdentifier : null; return ( derivedMeasureIdentifier && derivedMeasureIdentifier === measureItem.measureHeaderItem.localIdentifier ); }); } function findMeasureIndex(afm: AFM.IAfm, measureIdentifier: string): number { return afm.measures.findIndex((measure: AFM.IMeasure) => measure.localIdentifier === measureIdentifier); } export function findParentMeasureIndex(afm: AFM.IAfm, measureItemIndex: number): number { const measureDefinition = afm.measures[measureItemIndex].definition; if (AFM.isPopMeasureDefinition(measureDefinition)) { const sourceMeasureIdentifier = measureDefinition.popMeasure.measureIdentifier; return findMeasureIndex(afm, sourceMeasureIdentifier); } if (AFM.isPreviousPeriodMeasureDefinition(measureDefinition)) { const sourceMeasureIdentifier = measureDefinition.previousPeriodMeasure.measureIdentifier; return findMeasureIndex(afm, sourceMeasureIdentifier); } return -1; } export function getSeriesItemData( seriesItem: string[], seriesIndex: number, measureGroup: Execution.IMeasureGroupHeader["measureGroupHeader"], viewByAttribute: IUnwrappedAttributeHeaderWithItems, stackByAttribute: IUnwrappedAttributeHeaderWithItems, type: string, colorStrategy: IColorStrategy, ) { return seriesItem.map((pointValue: string, pointIndex: number) => { // by default seriesIndex corresponds to measureGroup label index let measureIndex = seriesIndex; // by default pointIndex corresponds to viewBy label index let viewByIndex = pointIndex; // drillContext can have 1 to 3 items // viewBy attribute label, stackby label if available // last drillContextItem is always current serie measure if (stackByAttribute) { // pointIndex corresponds to viewBy attribute label (if available) viewByIndex = pointIndex; // stack bar chart has always just one measure measureIndex = 0; } else if (isOneOfTypes(type, multiMeasuresAlternatingTypes) && !viewByAttribute) { measureIndex = pointIndex; } let valueProp: any = { y: parseValue(pointValue), }; if (isTreemap(type)) { valueProp = { value: parseValue(pointValue), }; } const pointData: IPointData = { ...valueProp, format: unwrap(measureGroup.items[measureIndex]).format, marker: { enabled: pointValue !== null, }, }; if (stackByAttribute) { // if there is a stackBy attribute, then seriesIndex corresponds to stackBy label index pointData.name = unwrap(stackByAttribute.items[seriesIndex]).name; } else if (isOneOfTypes(type, multiMeasuresAlternatingTypes) && viewByAttribute) { pointData.name = unwrap(viewByAttribute.items[viewByIndex]).name; } else { pointData.name = unwrap(measureGroup.items[measureIndex]).name; } if (isOneOfTypes(type, multiMeasuresAlternatingTypes)) { pointData.color = colorStrategy.getColorByIndex(pointIndex); // Pie and Treemap charts use pointData viewByIndex as legendIndex if available // instead of seriesItem legendIndex pointData.legendIndex = viewByAttribute ? viewByIndex : pointIndex; } return pointData; }); } export function getHeatmapSeries( executionResultData: Execution.DataValue[][], measureGroup: Execution.IMeasureGroupHeader["measureGroupHeader"], ) { const data: IPointData[] = []; executionResultData.forEach((rowItem: Execution.DataValue[], rowItemIndex: number) => { rowItem.forEach((columnItem: Execution.DataValue, columnItemIndex: number) => { const value: number = parseValue(String(columnItem)); const pointData: IPointData = { x: columnItemIndex, y: rowItemIndex, value }; if (isNil(value)) { data.push({ ...pointData, borderWidth: 1, borderColor: GRAY, color: TRANSPARENT, }); data.push({ ...pointData, borderWidth: 0, pointPadding: 2, color: nullColor, // ignoredInDrillEventContext flag is used internally, not related to Highchart // to check and remove this null-value point in drill message ignoredInDrillEventContext: true, }); } else { data.push(pointData); } }); }); return [ { name: measureGroup.items[0].measureHeaderItem.name, data, turboThreshold: 0, yAxis: 0, dataLabels: { formatGD: unwrap(measureGroup.items[0]).format, }, legendIndex: 0, }, ]; } export function getScatterPlotSeries( executionResultData: Execution.DataValue[][], stackByAttribute: any, mdObject: VisualizationObject.IVisualizationObjectContent, colorStrategy: IColorStrategy, ) { const buckets: VisualizationObject.IBucket[] = get(mdObject, "buckets", []); const primaryMeasuresBucketEmpty = isBucketEmpty(buckets, MEASURES); const secondaryMeasuresBucketEmpty = isBucketEmpty(buckets, SECONDARY_MEASURES); const data: ISeriesDataItem[] = executionResultData.map((seriesItem: string[], seriesIndex: number) => { const values = seriesItem.map((value: string) => { return parseValue(value); }); return { x: !primaryMeasuresBucketEmpty ? values[0] : 0, y: !secondaryMeasuresBucketEmpty ? (primaryMeasuresBucketEmpty ? values[0] : values[1]) : 0, name: stackByAttribute ? stackByAttribute.items[seriesIndex].attributeHeaderItem.name : "", }; }); return [ { turboThreshold: 0, color: colorStrategy.getColorByIndex(0), legendIndex: 0, data, }, ]; } function getCountOfEmptyBuckets(bucketEmptyFlags: boolean[] = []) { return bucketEmptyFlags.filter(bucketEmpyFlag => bucketEmpyFlag).length; } export function getBubbleChartSeries( executionResultData: Execution.DataValue[][], measureGroup: Execution.IMeasureGroupHeader["measureGroupHeader"], stackByAttribute: any, mdObject: VisualizationObject.IVisualizationObjectContent, colorStrategy: IColorStrategy, ) { const primaryMeasuresBucket = get(mdObject, ["buckets"], []).find( bucket => bucket.localIdentifier === MEASURES, ); const secondaryMeasuresBucket = get(mdObject, ["buckets"], []).find( bucket => bucket.localIdentifier === SECONDARY_MEASURES, ); const primaryMeasuresBucketEmpty = isEmpty(get(primaryMeasuresBucket, "items", [])); const secondaryMeasuresBucketEmpty = isEmpty(get(secondaryMeasuresBucket, "items", [])); return executionResultData.map((resData: any, index: number) => { let data: any = []; if (resData[0] !== null && resData[1] !== null && resData[2] !== null) { const emptyBucketsCount = getCountOfEmptyBuckets([ primaryMeasuresBucketEmpty, secondaryMeasuresBucketEmpty, ]); data = [ { x: !primaryMeasuresBucketEmpty ? parseValue(resData[0]) : 0, y: !secondaryMeasuresBucketEmpty ? parseValue(resData[1 - emptyBucketsCount]) : 0, // we want to allow NaN on z to be able show bubble of default size when Size bucket is empty z: parseFloat(resData[2 - emptyBucketsCount]), format: unwrap(last(measureGroup.items)).format, // only for dataLabel format }, ]; } return { name: stackByAttribute ? stackByAttribute.items[index].attributeHeaderItem.name : "", color: colorStrategy.getColorByIndex(index), legendIndex: index, data, }; }); } function getColorStep(valuesCount: number): number { const MAX_COLOR_BRIGHTNESS = 0.8; return MAX_COLOR_BRIGHTNESS / valuesCount; } function gradientPreviousGroup(solidColorLeafs: any[]): any[] { const colorChange = getColorStep(solidColorLeafs.length); return solidColorLeafs.map((leaf: any, index: number) => ({ ...leaf, color: getLighterColor(leaf.color, colorChange * index), })); } function getRootPoint(rootName: string, index: number, format: string, colorStrategy: IColorStrategy) { return { id: `${index}`, name: rootName, color: colorStrategy.getColorByIndex(index), showInLegend: true, legendIndex: index, format, }; } function getLeafPoint( stackByAttribute: any, parentIndex: number, seriesIndex: number, data: any, format: string, colorStrategy: IColorStrategy, ) { return { name: stackByAttribute.items[seriesIndex].attributeHeaderItem.name, parent: `${parentIndex}`, value: parseValue(data), x: seriesIndex, y: seriesIndex, showInLegend: false, color: colorStrategy.getColorByIndex(parentIndex), format, }; } function isLastSerie(seriesIndex: number, dataLength: number) { return seriesIndex === dataLength - 1; } export function getTreemapStackedSeriesDataWithViewBy( executionResultData: Execution.DataValue[][], measureGroup: Execution.IMeasureGroupHeader["measureGroupHeader"], viewByAttribute: IUnwrappedAttributeHeaderWithItems, stackByAttribute: IUnwrappedAttributeHeaderWithItems, colorStrategy: IColorStrategy, ): any[] { const roots: any = []; const leafs: any = []; let rootId = -1; let uncoloredLeafs: any = []; let lastRoot: Execution.IResultAttributeHeaderItem["attributeHeaderItem"] = null; const dataLength = executionResultData.length; const format = unwrap(measureGroup.items[0]).format; // this configuration has only one measure executionResultData.forEach((seriesItems: string[], seriesIndex: number) => { const currentRoot = viewByAttribute.items[seriesIndex].attributeHeaderItem; if (!isEqual(currentRoot, lastRoot)) { // store previous group leafs leafs.push(...gradientPreviousGroup(uncoloredLeafs)); rootId++; lastRoot = currentRoot; uncoloredLeafs = []; // create parent for pasted leafs const lastRootName: string = get(lastRoot, "name"); roots.push(getRootPoint(lastRootName, rootId, format, colorStrategy)); } // create leafs which will be colored at the end of group uncoloredLeafs.push( getLeafPoint(stackByAttribute, rootId, seriesIndex, seriesItems[0], format, colorStrategy), ); if (isLastSerie(seriesIndex, dataLength)) { // store last group leafs leafs.push(...gradientPreviousGroup(uncoloredLeafs)); } }); return [...roots, ...leafs]; // roots need to be first items in data to keep legend working } export function getTreemapStackedSeriesDataWithMeasures( executionResultData: Execution.DataValue[][], measureGroup: Execution.IMeasureGroupHeader["measureGroupHeader"], stackByAttribute: any, colorStrategy: IColorStrategy, ): any[] { let data: any = []; measureGroup.items.reduce((data: any[], measureGroupItem: any, index: number) => { data.push({ id: `${index}`, name: measureGroupItem.measureHeaderItem.name, format: measureGroupItem.measureHeaderItem.format, color: colorStrategy.getColorByIndex(index), showInLegend: true, legendIndex: index, }); return data; }, data); executionResultData.forEach((seriesItems: string[], seriesIndex: number) => { const colorChange = getColorStep(seriesItems.length); const unsortedLeafs: any[] = []; seriesItems.forEach((seriesItem: string, seriesItemIndex: number) => { unsortedLeafs.push({ name: stackByAttribute.items[seriesItemIndex].attributeHeaderItem.name, parent: `${seriesIndex}`, format: unwrap(measureGroup.items[seriesIndex]).format, value: parseValue(seriesItem), x: seriesIndex, y: seriesItemIndex, showInLegend: false, }); }); const sortedLeafs = unsortedLeafs.sort((a: IPointData, b: IPointData) => b.value - a.value); data = [ ...data, ...sortedLeafs.map((leaf: IPointData, seriesItemIndex: number) => ({ ...leaf, color: getLighterColor( colorStrategy.getColorByIndex(seriesIndex), colorChange * seriesItemIndex, ), })), ]; }); return data; } export function getTreemapStackedSeries( executionResultData: Execution.DataValue[][], measureGroup: Execution.IMeasureGroupHeader["measureGroupHeader"], viewByAttribute: IUnwrappedAttributeHeaderWithItems, stackByAttribute: IUnwrappedAttributeHeaderWithItems, colorStrategy: IColorStrategy, ) { let data = []; if (viewByAttribute) { data = getTreemapStackedSeriesDataWithViewBy( executionResultData, measureGroup, viewByAttribute, stackByAttribute, colorStrategy, ); } else { data = getTreemapStackedSeriesDataWithMeasures( executionResultData, measureGroup, stackByAttribute, colorStrategy, ); } const seriesName = measureGroup.items .map((wrappedMeasure: Execution.IMeasureHeaderItem) => { return unwrap(wrappedMeasure).name; }) .join(", "); return [ { name: seriesName, legendType: "point", showInLegend: true, data, turboThreshold: 0, }, ]; } export function getSeries( executionResultData: Execution.DataValue[][], measureGroup: Execution.IMeasureGroupHeader["measureGroupHeader"], viewByAttribute: IUnwrappedAttributeHeaderWithItems, stackByAttribute: IUnwrappedAttributeHeaderWithItems, type: string, mdObject: VisualizationObject.IVisualizationObjectContent, colorStrategy: IColorStrategy, occupiedMeasureBucketsLocalIdentifiers?: VisualizationObject.Identifier[], ): any { if (isHeatmap(type)) { return getHeatmapSeries(executionResultData, measureGroup); } else if (isScatterPlot(type)) { return getScatterPlotSeries(executionResultData, stackByAttribute, mdObject, colorStrategy); } else if (isBubbleChart(type)) { return getBubbleChartSeries( executionResultData, measureGroup, stackByAttribute, mdObject, colorStrategy, ); } else if (isTreemap(type) && stackByAttribute) { return getTreemapStackedSeries( executionResultData, measureGroup, viewByAttribute, stackByAttribute, colorStrategy, ); } else if (isBulletChart(type)) { return getBulletChartSeries( executionResultData, measureGroup, colorStrategy, occupiedMeasureBucketsLocalIdentifiers, ); } return executionResultData.map((seriesItem: string[], seriesIndex: number) => { const seriesItemData = getSeriesItemData( seriesItem, seriesIndex, measureGroup, viewByAttribute, stackByAttribute, type, colorStrategy, ); const seriesItemConfig: ISeriesItemConfig = { color: colorStrategy.getColorByIndex(seriesIndex), legendIndex: seriesIndex, data: seriesItemData, }; if (stackByAttribute) { // if stackBy attribute is available, seriesName is a stackBy attribute value of index seriesIndex // this is a limitiation of highcharts and a reason why you can not have multi-measure stacked charts seriesItemConfig.name = stackByAttribute.items[seriesIndex].attributeHeaderItem.name; } else if (isOneOfTypes(type, multiMeasuresAlternatingTypes) && !viewByAttribute) { // Pie charts with measures only have a single series which name would is ambiguous seriesItemConfig.name = measureGroup.items .map((wrappedMeasure: Execution.IMeasureHeaderItem) => { return unwrap(wrappedMeasure).name; }) .join(", "); } else { // otherwise seriesName is a measure name of index seriesIndex seriesItemConfig.name = measureGroup.items[seriesIndex].measureHeaderItem.name; } const turboThresholdProp = isTreemap(type) ? { turboThreshold: 0 } : {}; return { ...seriesItemConfig, ...turboThresholdProp, }; }); } export const customEscape = (str: string) => str && escape(unescape(str)); const renderTooltipHTML = (textData: string[][], maxTooltipContentWidth: number): string => { const maxItemWidth = maxTooltipContentWidth - TOOLTIP_PADDING * 2; const titleMaxWidth = maxItemWidth; const multiLineTruncationSupported = isCssMultiLineTruncationSupported(); const threeDotsWidth = 16; const valueMaxWidth = multiLineTruncationSupported ? maxItemWidth : maxItemWidth - threeDotsWidth; const titleStyle = `style="max-width: ${titleMaxWidth}px;"`; const valueStyle = `style="max-width: ${valueMaxWidth}px;"`; const itemClass = cx("gd-viz-tooltip-item", { "multiline-supported": multiLineTruncationSupported, }); const valueClass = cx("gd-viz-tooltip-value", { "clamp-two-line": multiLineTruncationSupported, }); return textData .map((item: string[]) => { // the third span is hidden, that help to have tooltip work with max-width return `<div class="${itemClass}"> <span class="gd-viz-tooltip-title" ${titleStyle}>${item[0]}</span> <div class="gd-viz-tooltip-value-wraper" ${titleStyle}> <span class="${valueClass}" ${valueStyle}>${item[1]}</span> </div> <div class="gd-viz-tooltip-value-wraper" ${titleStyle}> <span class="gd-viz-tooltip-value-max-content" ${valueStyle}>${item[1]}</span> </div> </div>`; }) .join("\n"); }; function isPointOnOppositeAxis(point: IPointData): boolean { return get(point, ["series", "yAxis", "opposite"], false); } export function buildTooltipFactory( viewByAttribute: IUnwrappedAttributeHeaderWithItems, type: string, config: IChartConfig = {}, isDualAxis: boolean = false, ): ITooltipFactory { const { separators, stackMeasuresToPercent = false } = config; return (point: IPointData, maxTooltipContentWidth: number, percentageValue?: number): string => { const isDualChartWithRightAxis = isDualAxis && isPointOnOppositeAxis(point); const formattedValue = getFormattedValueForTooltip( isDualChartWithRightAxis, stackMeasuresToPercent, point, separators, percentageValue, ); const textData = [[customEscape(point.series.name), formattedValue]]; if (viewByAttribute) { // For some reason, highcharts ommit categories for pie charts with attribute. Use point.name instead. // use attribute name instead of attribute display form name textData.unshift([ customEscape(viewByAttribute.formOf.name), // since applying 'grouped-categories' plugin, // 'category' type is replaced from string to object in highchart customEscape((point.category && point.category.name) || point.name), ]); } else if (isOneOfTypes(type, multiMeasuresAlternatingTypes)) { // Pie charts with measure only have to use point.name instead of series.name to get the measure name textData[0][0] = customEscape(point.name); } return renderTooltipHTML(textData, maxTooltipContentWidth); }; } export function buildTooltipForTwoAttributesFactory( viewByAttribute: IUnwrappedAttributeHeaderWithItems, viewByParentAttribute: IUnwrappedAttributeHeaderWithItems, config: IChartConfig = {}, isDualAxis: boolean = false, ): ITooltipFactory { const { separators, stackMeasuresToPercent = false } = config; return (point: IPointData, maxTooltipContentWidth: number, percentageValue?: number): string => { const category: ICategory = point.category; const isDualChartWithRightAxis = isDualAxis && isPointOnOppositeAxis(point); const formattedValue = getFormattedValueForTooltip( isDualChartWithRightAxis, stackMeasuresToPercent, point, separators, percentageValue, ); const textData = [[customEscape(point.series.name), formattedValue]]; if (category) { if (viewByAttribute) { textData.unshift([customEscape(viewByAttribute.formOf.name), customEscape(category.name)]); } if (viewByParentAttribute && category.parent) { textData.unshift([ customEscape(viewByParentAttribute.formOf.name), customEscape(category.parent.name), ]); } } return renderTooltipHTML(textData, maxTooltipContentWidth); }; } export function generateTooltipXYFn( measures: any, stackByAttribute: IUnwrappedAttributeHeaderWithItems, config: IChartConfig = {}, ): ITooltipFactory { const { separators } = config; return (point: IPointData, maxTooltipContentWidth: number): string => { const textData = []; const name = point.name ? point.name : point.series.name; if (stackByAttribute) { textData.unshift([customEscape(stackByAttribute.formOf.name), customEscape(name)]); } if (measures[0]) { textData.push([ customEscape(measures[0].measureHeaderItem.name), formatValueForTooltip(point.x, measures[0].measureHeaderItem.format, separators), ]); } if (measures[1]) { textData.push([ customEscape(measures[1].measureHeaderItem.name), formatValueForTooltip(point.y, measures[1].measureHeaderItem.format, separators), ]); } if (measures[2]) { textData.push([ customEscape(measures[2].measureHeaderItem.name), formatValueForTooltip(point.z, measures[2].measureHeaderItem.format, separators), ]); } return renderTooltipHTML(textData, maxTooltipContentWidth); }; } export function generateTooltipHeatmapFn( viewByAttribute: any, stackByAttribute: any, config: IChartConfig = {}, ): ITooltipFactory { const { separators } = config; const formatValue = (val: number, format: string) => { return colors2Object(val === null ? "-" : numberFormat(val, format, undefined, separators)); }; return (point: IPointData, maxTooltipContentWidth: number): string => { const formattedValue = customEscape( formatValue(point.value, point.series.userOptions.dataLabels.formatGD).label, ); const textData = []; textData.unshift([customEscape(point.series.name), formattedValue]); if (viewByAttribute) { textData.unshift([ customEscape(viewByAttribute.formOf.name), customEscape(viewByAttribute.items[point.x].attributeHeaderItem.name), ]); } if (stackByAttribute) { textData.unshift([ customEscape(stackByAttribute.formOf.name), customEscape(stackByAttribute.items[point.y].attributeHeaderItem.name), ]); } return renderTooltipHTML(textData, maxTooltipContentWidth); }; } export function buildTooltipTreemapFactory( viewByAttribute: IUnwrappedAttributeHeaderWithItems, stackByAttribute: IUnwrappedAttributeHeaderWithItems, config: IChartConfig = {}, ): ITooltipFactory { const { separators } = config; return (point: IPointData, maxTooltipContentWidth: number) => { // show tooltip for leaf node only if (!point.node || point.node.isLeaf === false) { return null; } const formattedValue = formatValueForTooltip(point.value, point.format, separators); const textData = []; if (stackByAttribute) { textData.push([ customEscape(stackByAttribute.formOf.name), customEscape(stackByAttribute.items[point.y].attributeHeaderItem.name), ]); } if (viewByAttribute) { textData.unshift([ customEscape(viewByAttribute.formOf.name), customEscape(viewByAttribute.items[point.x].attributeHeaderItem.name), ]); textData.push([customEscape(point.series.name), formattedValue]); } else { textData.push([customEscape(point.category && point.category.name), formattedValue]); } return renderTooltipHTML(textData, maxTooltipContentWidth); }; } export interface ILegacyMeasureHeader { uri: string; // header attribute value or measure uri identifier?: string; localIdentifier?: string; name: string; // header attribute value or measure text label format?: string; // format of measure } export interface ILegacyAttributeHeader extends ILegacyMeasureHeader { attribute: any; } export type ILegacyHeader = ILegacyAttributeHeader | ILegacyMeasureHeader; export function isLegacyAttributeHeader(header: ILegacyHeader): header is ILegacyAttributeHeader { return (header as ILegacyAttributeHeader).attribute !== undefined; } function getViewBy( viewByAttribute: IUnwrappedAttributeHeaderWithItems, viewByIndex: number, ): { viewByItemHeader: Execution.IResultAttributeHeaderItem; viewByAttributeHeader: Execution.IAttributeHeader; } { let viewByItemHeader: Execution.IResultAttributeHeaderItem = null; let viewByAttributeHeader: Execution.IAttributeHeader = null; if (viewByAttribute) { viewByItemHeader = viewByAttribute.items[viewByIndex]; viewByAttributeHeader = { attributeHeader: omit(viewByAttribute, "items") }; } return { viewByItemHeader, viewByAttributeHeader, }; } function getStackBy( stackByAttribute: IUnwrappedAttributeHeaderWithItems, stackByIndex: number, ): { stackByItemHeader: Execution.IResultAttributeHeaderItem; stackByAttributeHeader: Execution.IAttributeHeader; } { let stackByItemHeader: Execution.IResultAttributeHeaderItem = null; let stackByAttributeHeader: Execution.IAttributeHeader = null; if (stackByAttribute) { // stackBy item index is always equal to seriesIndex stackByItemHeader = stackByAttribute.items[stackByIndex]; stackByAttributeHeader = { attributeHeader: omit(stackByAttribute, "items") }; } return { stackByItemHeader, stackByAttributeHeader, }; } export function getDrillableSeries( series: any, drillableItems: IHeaderPredicate[], viewByAttributes: IUnwrappedAttributeHeaderWithItems[], stackByAttribute: IUnwrappedAttributeHeaderWithItems, executionResponse: Execution.IExecutionResponse, afm: AFM.IAfm, type: VisType, ) { const [viewByChildAttribute, viewByParentAttribute] = viewByAttributes; const isMultiMeasureWithOnlyMeasures = isOneOfTypes(type, multiMeasuresAlternatingTypes) && !viewByChildAttribute; const measureGroup = findMeasureGroupInDimensions(executionResponse.dimensions); return series.map((seriesItem: any, seriesIndex: number) => { let isSeriesDrillable = false; let data = seriesItem.data && seriesItem.data.map((pointData: IPointData, pointIndex: number) => { let measureHeaders: Execution.IMeasureHeaderItem[] = []; const isStackedTreemap = isTreemap(type) && !!stackByAttribute; if (isScatterPlot(type)) { measureHeaders = get(measureGroup, "items", []).slice(0, 2); } else if (isBubbleChart(type)) { measureHeaders = get(measureGroup, "items", []).slice(0, 3); } else if (isStackedTreemap) { if (pointData.id !== undefined) { // not leaf -> can't be drillable return pointData; } const measureIndex = viewByChildAttribute ? 0 : parseInt(pointData.parent, 10); measureHeaders = [measureGroup.items[measureIndex]]; } else { // measureIndex is usually seriesIndex, // except for stack by attribute and metricOnly pie or donut chart // it is looped-around pointIndex instead // Looping around the end of items array only works when // measureGroup is the last header on it's dimension // We do not support setups with measureGroup before attributeHeaders const measureIndex = !stackByAttribute && !isMultiMeasureWithOnlyMeasures ? seriesIndex : pointIndex % measureGroup.items.length; measureHeaders = [measureGroup.items[measureIndex]]; } const viewByIndex = isHeatmap(type) || isStackedTreemap ? pointData.x : pointIndex; let stackByIndex = isHeatmap(type) || isStackedTreemap ? pointData.y : seriesIndex; if (isScatterPlot(type)) { stackByIndex = viewByIndex; // scatter plot uses stack by attribute but has only one serie } const { stackByItemHeader, stackByAttributeHeader } = getStackBy( stackByAttribute, stackByIndex, ); const { viewByItemHeader: viewByChildItemHeader, viewByAttributeHeader: viewByChildAttributeHeader, } = getViewBy(viewByChildAttribute, viewByIndex); const { viewByItemHeader: viewByParentItemHeader, viewByAttributeHeader: viewByParentAttributeHeader, } = getViewBy(viewByParentAttribute, viewByIndex); // point is drillable if a drillableItem matches: // point's measure, // point's viewBy attribute, // point's viewBy attribute item, // point's stackBy attribute, // point's stackBy attribute item, const drillableHooks: IMappingHeader[] = without( [ ...measureHeaders, viewByChildAttributeHeader, viewByChildItemHeader, viewByParentAttributeHeader, viewByParentItemHeader, stackByAttributeHeader, stackByItemHeader, ], null, ); const drilldown: boolean = drillableHooks.some(drillableHook => isSomeHeaderPredicateMatched(drillableItems, drillableHook, afm, executionResponse), ); const drillableProps: { drilldown: boolean; drillIntersection?: IDrillEventIntersectionElementExtended[]; } = { drilldown, }; if (drilldown) { const headers: IMappingHeader[] = [ ...measureHeaders, viewByChildItemHeader, viewByChildAttributeHeader, viewByParentItemHeader, viewByParentAttributeHeader, stackByItemHeader, stackByAttributeHeader, ]; const sanitizedHeaders = without([...headers], null); drillableProps.drillIntersection = getDrillIntersection(sanitizedHeaders); isSeriesDrillable = true; } return { ...pointData, ...drillableProps, }; }); if (isScatterPlot(type)) { data = data.filter((dataItem: ISeriesDataItem) => { return dataItem.x !== null && dataItem.y !== null; }); } return { ...seriesItem, data, isDrillable: isSeriesDrillable, }; }); } function getCategories( type: string, measureGroup: Execution.IMeasureGroupHeader["measureGroupHeader"], viewByAttribute: IUnwrappedAttributeHeaderWithItems, stackByAttribute: IUnwrappedAttributeHeaderWithItems, ) { if (isHeatmap(type)) { return [ viewByAttribute ? viewByAttribute.items.map((item: any) => item.attributeHeaderItem.name) : [""], stackByAttribute ? stackByAttribute.items.map((item: any) => item.attributeHeaderItem.name) : [""], ]; } if (isScatterPlot(type)) { return stackByAttribute ? stackByAttribute.items.map((item: any) => item.attributeHeaderItem.name) : [""]; } // Categories make up bar/slice labels in charts. These usually match view by attribute values. // Measure only pie or treemap charts get categories from measure names if (viewByAttribute) { return viewByAttribute.items.map(({ attributeHeaderItem }: any) => attributeHeaderItem.name); } if (isOneOfTypes(type, multiMeasuresAlternatingTypes)) { // Pie or Treemap chart with measures only (no viewByAttribute) needs to list return measureGroup.items.map( (wrappedMeasure: Execution.IMeasureHeaderItem) => unwrap(wrappedMeasure).name, ); // Pie chart categories are later sorted by seriesItem pointValue } return []; } function getStackingConfig( stackByAttribute: IUnwrappedAttributeHeaderWithItems, options: IChartConfig, ): string { const { type, stackMeasures, stackMeasuresToPercent } = options; const stackingValue = stackMeasuresToPercent ? PERCENT_STACK : NORMAL_STACK; const supportsStacking = !isOneOfTypes(type, unsupportedStackingTypes); /** * we should enable stacking for one of the following cases : * 1) If stackby attribute have been set and chart supports stacking * 2) If chart is an area chart and stacking is enabled (stackBy attribute doesn't matter) * 3) If chart is column/bar chart and 'Stack Measures' is enabled */ const isStackByChart = stackByAttribute && supportsStacking; const isAreaChartWithEnabledStacking = isAreaChartStackingEnabled(options); if (isStackByChart || isAreaChartWithEnabledStacking || stackMeasures || stackMeasuresToPercent) { return stackingValue; } return null; // no stacking } function preprocessMeasureGroupItems( measureGroup: Execution.IMeasureGroupHeader["measureGroupHeader"], defaultValues: any, ): any[] { return measureGroup.items.map((item: Execution.IMeasureHeaderItem, index: number) => { const unwrapped = unwrap(item); return index ? { label: unwrapped.name, format: unwrapped.format, } : { label: defaultValues.label || unwrapped.name, format: defaultValues.format || unwrapped.format, }; }); } function getXAxes( config: IChartConfig, measureGroup: Execution.IMeasureGroupHeader["measureGroupHeader"], viewByAttribute: IUnwrappedAttributeHeaderWithItems, ): IAxis[] { const { type, mdObject } = config; const buckets: VisualizationObject.IBucket[] = get(mdObject, "buckets", []); const measureGroupItems = preprocessMeasureGroupItems(measureGroup, { label: config.xLabel, format: config.xFormat, }); const firstMeasureGroupItem = measureGroupItems[0]; if (isScatterPlot(type) || isBubbleChart(type)) { const noPrimaryMeasures = isBucketEmpty(buckets, MEASURES); if (noPrimaryMeasures) { return [ { label: "", }, ]; } else { return [ { label: firstMeasureGroupItem.label || "", format: firstMeasureGroupItem.format || "", }, ]; } } const xLabel = config.xLabel || (viewByAttribute ? viewByAttribute.formOf.name : ""); return [ { label: xLabel, }, ]; } function getMeasureFormatKey(measureGroupItems: Execution.IMeasureHeaderItem[]) { const percentageFormat = getMeasureFormat( measureGroupItems.find((measure: any) => { return isPercentage(getMeasureFormat(measure)); }), ); return percentageFormat !== "" ? { format: percentageFormat, } : {}; } function getMeasureFormat(measure: any) { return get(measure, "format", ""); } function isPercentage(format: string) { return format.includes("%"); } function getYAxes( config: IChartConfig, measureGroup: Execution.IMeasureGroupHeader["measureGroupHeader"], stackByAttribute: any, ): IAxis[] { const { type, mdObject } = config; const buckets: VisualizationObject.IBucket[] = get(mdObject, "buckets", []); const measureGroupItems = preprocessMeasureGroupItems(measureGroup, { label: config.yLabel, format: config.yFormat, }); const firstMeasureGroupItem = measureGroupItems[0]; const secondMeasureGroupItem = measureGroupItems[1]; const hasMoreThanOneMeasure = measureGroupItems.length > 1; const noPrimaryMeasures = isBucketEmpty(buckets, MEASURES); const { measures: secondaryAxisMeasures = [] as string[] } = (isBarChart(type) ? config.secondary_xaxis : config.secondary_yaxis) || {}; let yAxes: IAxis[] = []; if (isScatterPlot(type) || isBubbleChart(type)) { const hasSecondaryMeasure = get(mdObject, "buckets", []).find( m => m.localIdentifier === SECONDARY_MEASURES && m.items.length > 0, ); if (hasSecondaryMeasure) { if (noPrimaryMeasures) { yAxes = [ { ...firstMeasureGroupItem, }, ]; } else { yAxes = [ { ...secondMeasureGroupItem, }, ]; } } else { yAxes = [{ label: "" }]; } } else if (isHeatmap(type)) { yAxes = [ { label: stackByAttribute ? stackByAttribute.formOf.name : "", }, ]; } else if ( isOneOfTypes(type, supportedDualAxesChartTypes) && !isEmpty(measureGroupItems) && !isEmpty(secondaryAxisMeasures) ) { const { measuresInFirstAxis, measuresInSecondAxis }: IMeasuresInAxes = assignMeasuresToAxes( secondaryAxisMeasures, measureGroup, ); let firstAxis: IAxis = createYAxisItem(measuresInFirstAxis, false); let secondAxis: IAxis = createYAxisItem(measuresInSecondAxis, true); if (firstAxis) { firstAxis = { ...firstAxis, ...getMeasureFormatKey(measuresInFirstAxis), seriesIndices: measuresInFirstAxis.map(({ index }: any) => index), }; } if (secondAxis) { secondAxis = { ...secondAxis, ...getMeasureFormatKey(measuresInSecondAxis), seriesIndices: measuresInSecondAxis.map(({ index }: any) => index), }; } yAxes = compact([firstAxis, secondAxis]); } else { // if more than one m