UNPKG

@gooddata/react-components

Version:

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

598 lines (533 loc) • 21.9 kB
// (C) 2007-2020 GoodData Corporation import range = require("lodash/range"); import get = require("lodash/get"); import head = require("lodash/head"); import last = require("lodash/last"); import isEmpty = require("lodash/isEmpty"); import inRange = require("lodash/inRange"); import { IColorLegendItem } from "../../typings/legend"; import { LEFT, RIGHT, TOP, BOTTOM } from "./PositionTypes"; import { formatLegendLabel, isAreaChart, isOneOfTypes, isTreemap } from "../../utils/common"; import { supportedDualAxesChartTypes } from "../chartOptionsBuilder"; import { ISeriesItem } from "../../../../interfaces/Config"; import { VisualizationTypes } from "../../../../constants/visualizationTypes"; export const RESPONSIVE_ITEM_MIN_WIDTH = 200; export const RESPONSIVE_VISIBLE_ROWS = 2; export const FLUID_PAGING_WIDTH = 30; export const LEGEND_PADDING = 12; export const ITEM_HEIGHT = 20; export const SKIPPED_LABEL_TEXT = "..."; export const UTF_NON_BREAKING_SPACE = "\u00A0"; const STATIC_PAGING_HEIGHT = 44; export interface IColorLegendBox { class: string; key: string; style?: { backgroundColor: string; border: string; }; } interface IColorLabelConfigItem { type: string; labelIndex?: number; style?: { width?: number; height?: number; lineHeight?: string; textAlign?: string; }; } export interface IHeatmapLegendLabel { class?: string; key: string; label: string; style: object; } export interface IColorLegendConfig { boxes: IColorLegendBox[]; classes: string[]; labels: IHeatmapLegendLabel[]; position: string; } function getEmptyBlock(style: any, index: number) { return { key: `empty-${index}`, label: UTF_NON_BREAKING_SPACE, style, }; } function getLabelStyle(width: number, textAlign: string) { return { width, textAlign }; } const ALEFT = "left"; const ARIGHT = "right"; const ACENTER = "center"; const DOTS_WIDTH = 10; function getSkippedLabelBlock(index: number) { return { key: `dots-${index}`, label: SKIPPED_LABEL_TEXT, style: getLabelStyle(DOTS_WIDTH, ACENTER), }; } const verticalHeatmapMiddleLabelStyle = { height: 30, textAlign: ALEFT, lineHeight: "30px" }; export const verticalHeatmapConfig: IColorLabelConfigItem[] = [ { type: "label", labelIndex: 0, style: { height: 15, textAlign: ALEFT, lineHeight: "11px" } }, { type: "label", labelIndex: 1, style: verticalHeatmapMiddleLabelStyle }, { type: "label", labelIndex: 2, style: verticalHeatmapMiddleLabelStyle }, { type: "label", labelIndex: 3, style: verticalHeatmapMiddleLabelStyle }, { type: "label", labelIndex: 4, style: verticalHeatmapMiddleLabelStyle }, { type: "label", labelIndex: 5, style: verticalHeatmapMiddleLabelStyle }, { type: "label", labelIndex: 6, style: verticalHeatmapMiddleLabelStyle }, { type: "label", labelIndex: 7, style: { height: 15, textAlign: ALEFT, lineHeight: "20px" } }, ]; const defaultHeatmapLegendLabelStyle = { width: 40, textAlign: ACENTER }; export const heatmapLegendConfigMatrix: IColorLabelConfigItem[][] = [ [ { type: "label", labelIndex: 0, style: { width: 175, textAlign: ALEFT } }, { type: "label", labelIndex: 7, style: { width: 175, textAlign: ARIGHT } }, ], [ { type: "label", labelIndex: 0, style: { width: 145, textAlign: ALEFT } }, { type: "dots" }, { type: "space", style: { width: 40 } }, { type: "dots" }, { type: "label", labelIndex: 7, style: { width: 145, textAlign: ARIGHT } }, ], [ { type: "label", labelIndex: 0, style: { width: 95, textAlign: ALEFT } }, { type: "dots" }, { type: "space", style: { width: 40 } }, { type: "dots" }, { type: "space", style: { width: 40 } }, { type: "dots" }, { type: "space", style: { width: 40 } }, { type: "dots" }, { type: "label", labelIndex: 7, style: { width: 95, textAlign: ARIGHT } }, ], [ { type: "label", labelIndex: 0, style: { width: 55, textAlign: ALEFT } }, { type: "label", labelIndex: 2, style: { width: 90, textAlign: ACENTER } }, { type: "dots" }, { type: "space", style: { width: 40 } }, { type: "dots" }, { type: "label", labelIndex: 5, style: { width: 90, textAlign: ACENTER } }, { type: "label", labelIndex: 7, style: { width: 55, textAlign: ARIGHT } }, ], [ { type: "label", labelIndex: 0, style: { width: 45, textAlign: ALEFT } }, { type: "dots" }, { type: "label", labelIndex: 2, style: { width: 90, textAlign: ACENTER } }, { type: "dots" }, { type: "space", style: { width: 40 } }, { type: "dots" }, { type: "label", labelIndex: 5, style: { width: 90, textAlign: ACENTER } }, { type: "dots" }, { type: "label", labelIndex: 7, style: { width: 45, textAlign: ARIGHT } }, ], [ { type: "label", labelIndex: 0, style: { width: 30, textAlign: ALEFT } }, { type: "label", labelIndex: 1, style: defaultHeatmapLegendLabelStyle }, { type: "space", style: { width: 10 } }, { type: "label", labelIndex: 2, style: defaultHeatmapLegendLabelStyle }, { type: "space", style: { width: 10 } }, { type: "label", labelIndex: 3, style: defaultHeatmapLegendLabelStyle }, { type: "space", style: { width: 10 } }, { type: "label", labelIndex: 4, style: defaultHeatmapLegendLabelStyle }, { type: "space", style: { width: 10 } }, { type: "label", labelIndex: 5, style: defaultHeatmapLegendLabelStyle }, { type: "space", style: { width: 10 } }, { type: "label", labelIndex: 6, style: defaultHeatmapLegendLabelStyle }, { type: "label", labelIndex: 7, style: { width: 30, textAlign: ARIGHT } }, ], ]; export const colorLegendConfigMatrix: IColorLabelConfigItem[][] = [ [ { type: "label", labelIndex: 0, style: { width: 175, textAlign: ALEFT } }, { type: "label", labelIndex: 6, style: { width: 175, textAlign: ARIGHT } }, ], [ { type: "label", labelIndex: 0, style: { width: 145, textAlign: ALEFT } }, { type: "dots" }, { type: "space", style: { width: 40 } }, { type: "dots" }, { type: "label", labelIndex: 6, style: { width: 145, textAlign: ARIGHT } }, ], [ { type: "label", labelIndex: 0, style: { width: 95, textAlign: ALEFT } }, { type: "dots" }, { type: "space", style: { width: 40 } }, { type: "dots" }, { type: "space", style: { width: 40 } }, { type: "dots" }, { type: "space", style: { width: 40 } }, { type: "dots" }, { type: "label", labelIndex: 6, style: { width: 95, textAlign: ARIGHT } }, ], [ { type: "label", labelIndex: 0, style: { width: 55, textAlign: ALEFT } }, { type: "label", labelIndex: 2, style: { width: 90, textAlign: ACENTER } }, { type: "dots" }, { type: "space", style: { width: 40 } }, { type: "dots" }, { type: "label", labelIndex: 4, style: { width: 90, textAlign: ACENTER } }, { type: "label", labelIndex: 6, style: { width: 55, textAlign: ARIGHT } }, ], [ { type: "label", labelIndex: 0, style: { width: 45, textAlign: ALEFT } }, { type: "dots" }, { type: "label", labelIndex: 2, style: { width: 90, textAlign: ACENTER } }, { type: "dots" }, { type: "space", style: { width: 40 } }, { type: "dots" }, { type: "label", labelIndex: 4, style: { width: 90, textAlign: ACENTER } }, { type: "dots" }, { type: "label", labelIndex: 6, style: { width: 45, textAlign: ARIGHT } }, ], [ { type: "label", labelIndex: 0, style: { width: 30, textAlign: ALEFT } }, { type: "label", labelIndex: 1, style: defaultHeatmapLegendLabelStyle }, { type: "space", style: { width: 10 } }, { type: "label", labelIndex: 2, style: defaultHeatmapLegendLabelStyle }, { type: "space", style: { width: 10 } }, { type: "label", labelIndex: 3, style: defaultHeatmapLegendLabelStyle }, { type: "space", style: { width: 10 } }, { type: "label", labelIndex: 4, style: defaultHeatmapLegendLabelStyle }, { type: "space", style: { width: 10 } }, { type: "label", labelIndex: 5, style: defaultHeatmapLegendLabelStyle }, { type: "label", labelIndex: 6, style: { width: 30, textAlign: ARIGHT } }, ], ]; const defaultHeatmapSmallLegendStyle = { width: 40, textAlign: ACENTER }; export const heatmapSmallLegendConfigMatrix: IColorLabelConfigItem[][] = [ [ { type: "label", labelIndex: 0, style: { width: 138, textAlign: ALEFT } }, { type: "label", labelIndex: 7, style: { width: 138, textAlign: ARIGHT } }, ], [ { type: "label", labelIndex: 0, style: { width: 115, textAlign: ALEFT } }, { type: "dots" }, { type: "space", style: { width: 26 } }, { type: "dots" }, { type: "label", labelIndex: 7, style: { width: 115, textAlign: ARIGHT } }, ], [ { type: "label", labelIndex: 0, style: { width: 75, textAlign: ALEFT } }, { type: "dots" }, { type: "space", style: { width: 30 } }, { type: "dots" }, { type: "space", style: { width: 26 } }, { type: "dots" }, { type: "space", style: { width: 30 } }, { type: "dots" }, { type: "label", labelIndex: 7, style: { width: 75, textAlign: ARIGHT } }, ], [ { type: "label", labelIndex: 0, style: { width: 45, textAlign: ALEFT } }, { type: "label", labelIndex: 2, style: { width: 70, textAlign: ACENTER } }, { type: "dots" }, { type: "space", style: { width: 26 } }, { type: "dots" }, { type: "label", labelIndex: 5, style: { width: 70, textAlign: ACENTER } }, { type: "label", labelIndex: 7, style: { width: 45, textAlign: ARIGHT } }, ], [ { type: "label", labelIndex: 0, style: { width: 35, textAlign: ALEFT } }, { type: "dots" }, { type: "label", labelIndex: 2, style: { width: 70, textAlign: ACENTER } }, { type: "dots" }, { type: "space", style: { width: 26 } }, { type: "dots" }, { type: "label", labelIndex: 5, style: { width: 70, textAlign: ACENTER } }, { type: "dots" }, { type: "label", labelIndex: 7, style: { width: 35, textAlign: ARIGHT } }, ], [ { type: "label", labelIndex: 0, style: { width: 20, textAlign: ALEFT } }, { type: "label", labelIndex: 1, style: defaultHeatmapSmallLegendStyle }, { type: "label", labelIndex: 2, style: defaultHeatmapSmallLegendStyle }, { type: "label", labelIndex: 3, style: { width: 38, textAlign: ACENTER } }, { type: "label", labelIndex: 4, style: { width: 38, textAlign: ACENTER } }, { type: "label", labelIndex: 5, style: defaultHeatmapSmallLegendStyle }, { type: "label", labelIndex: 6, style: defaultHeatmapSmallLegendStyle }, { type: "label", labelIndex: 7, style: { width: 20, textAlign: ARIGHT } }, ], ]; export function buildColorLabelsConfig(labels: string[], config: any) { return config.map((element: any, index: number) => { switch (element.type) { case "label": return { label: labels[element.labelIndex], style: element.style, key: `${element.type}-${index}`, }; case "space": return getEmptyBlock(element.style, index); case "dots": return getSkippedLabelBlock(index); } }); } const LABEL_LENGHT_THRESHOLDS = [5, 8, 10, 15, 18]; const SMALL_LABEL_LENGHT_THRESHOLDS = [4, 7, 9, 13, 15]; function getColorLegendLabelsConfiguration(legendLabels: string[], isSmall: boolean, isVertical: boolean) { const numberOfLabels = legendLabels.length; const firstLabelLength = head(legendLabels).length; const lastLabelLength = last(legendLabels).length; const maxLabelLength = firstLabelLength > lastLabelLength ? firstLabelLength : lastLabelLength; const labelLengths = isSmall ? SMALL_LABEL_LENGHT_THRESHOLDS : LABEL_LENGHT_THRESHOLDS; const shorteningConfig = isVertical ? verticalHeatmapConfig : getHorizontalShorteningLabelConfig(labelLengths, maxLabelLength, isSmall, numberOfLabels); return buildColorLabelsConfig(legendLabels, shorteningConfig); } function getHorizontalShorteningLabelConfig( labelLengths: number[], maxLabelLength: number, isSmall: boolean, numberOfLabels: number, ): IColorLabelConfigItem[] { const shorteningLevel = getColorLabelShorteningLevel(labelLengths, maxLabelLength); if (isSmall) { return heatmapSmallLegendConfigMatrix[shorteningLevel]; } if (numberOfLabels === 8) { return heatmapLegendConfigMatrix[shorteningLevel]; } return colorLegendConfigMatrix[shorteningLevel]; } function getColorLabelShorteningLevel(labelLengths: number[], maxLabelLength: number): number { let shorteningLevel: number; if (inRange(maxLabelLength, 0, labelLengths[0])) { shorteningLevel = 5; } else if (inRange(maxLabelLength, labelLengths[0], labelLengths[1])) { shorteningLevel = 4; } else if (inRange(maxLabelLength, labelLengths[1], labelLengths[2])) { shorteningLevel = 3; } else if (inRange(maxLabelLength, labelLengths[2], labelLengths[3])) { shorteningLevel = 2; } else if (inRange(maxLabelLength, labelLengths[3], labelLengths[4])) { shorteningLevel = 1; } else { shorteningLevel = 0; } return shorteningLevel; } export function calculateFluidLegend(seriesCount: number, containerWidth: number) { // -1 because flex dimensions provide rounded number and the real width can be float const realWidth = containerWidth - 2 * LEGEND_PADDING - 1; if (seriesCount <= 2) { return { hasPaging: false, itemWidth: realWidth / seriesCount, visibleItemsCount: seriesCount, }; } let columnsCount = Math.floor(realWidth / RESPONSIVE_ITEM_MIN_WIDTH); let itemWidth = realWidth / columnsCount; let hasPaging = false; const rowsCount = Math.ceil(seriesCount / columnsCount); // Recalculate with paging if (rowsCount > RESPONSIVE_VISIBLE_ROWS) { const legendWidthWithPaging = realWidth - FLUID_PAGING_WIDTH; columnsCount = Math.floor(legendWidthWithPaging / RESPONSIVE_ITEM_MIN_WIDTH); itemWidth = legendWidthWithPaging / columnsCount; hasPaging = true; } const visibleItemsCount = columnsCount * RESPONSIVE_VISIBLE_ROWS; return { itemWidth, hasPaging, visibleItemsCount, }; } function getStaticVisibleItemsCount(containerHeight: number, withPaging: boolean = false) { const pagingHeight = withPaging ? STATIC_PAGING_HEIGHT : 0; return Math.floor((containerHeight - pagingHeight) / ITEM_HEIGHT); } export function calculateStaticLegend(seriesCount: number, containerHeight: number) { const visibleItemsCount = getStaticVisibleItemsCount(containerHeight); if (visibleItemsCount >= seriesCount) { return { hasPaging: false, visibleItemsCount, }; } return { hasPaging: true, visibleItemsCount: getStaticVisibleItemsCount(containerHeight, true), }; } function getColorLegendLabels( series: IColorLegendItem[], format: string, numericSymbols: string[], ): string[] { const min = get(head(series), "range.from", 0); const max = get(last(series), "range.to", 0); const diff = max - min; return range(series.length + 1).map(index => { let value: number; if (index === 0) { value = get(series, "0.range.from", 0); } else if (index === series.length) { value = get(series, `${index - 1}.range.to`, 0); } else { value = get(series, `${index}.range.from`, 0); } return formatLegendLabel(value, format, diff, numericSymbols); }); } const MIDDLE_LEGEND_BOX_INDEX = 3; function getColorBoxes(series: IColorLegendItem[]): IColorLegendBox[] { const getBoxStyle = (item: IColorLegendItem) => ({ backgroundColor: item.color, border: item.color === "rgb(255,255,255)" ? "1px solid #ccc" : "none", }); return series.map((item: IColorLegendItem, index: number) => { const style = getBoxStyle(item); const middle = index === MIDDLE_LEGEND_BOX_INDEX ? "middle" : null; return { class: middle, key: `item-${index}`, style, }; }); } export function getColorLegendConfiguration( series: IColorLegendItem[], format: string, numericSymbols: string[], isSmall: boolean, position: string, ): IColorLegendConfig { const legendLabels = getColorLegendLabels(series, format, numericSymbols); const small = isSmall ? "small" : null; let finalPosition; // tslint:disable-next-line:prefer-conditional-expression if (isSmall) { finalPosition = position === TOP ? TOP : BOTTOM; } else { finalPosition = position || RIGHT; } const classes = ["viz-legend", "color-legend", `position-${finalPosition}`, small]; const isVertical = finalPosition === LEFT || finalPosition === RIGHT; const finalLabels = getColorLegendLabelsConfiguration(legendLabels, isSmall, isVertical); const boxes = getColorBoxes(series); return { classes, labels: finalLabels, boxes, position: finalPosition, }; } const LEGEND_TEXT_KEYS = { column: ["left", "right"], line: ["left", "right"], bar: ["bottom", "top"], area: ["left", "right"], combo: ["left", "right"], combo2: ["left", "right"], }; export const LEGEND_AXIS_INDICATOR = "legendAxisIndicator"; export const LEGEND_SEPARATOR = "legendSeparator"; function separateLegendItems(series: any[]) { return series.reduce( (result: any, item: any) => { // for now, it assumes that GDC chart only has 2 Y axes in maximum // yAxis only takes 0 (left/bottom axis) or 1 (right/top axis) const { yAxis } = item; if (!yAxis) { // 0 result.itemsOnFirstAxis.push(item); } else { result.itemsOnSecondAxis.push(item); } return result; }, { itemsOnFirstAxis: [], itemsOnSecondAxis: [], }, ); } export function groupSeriesItemsByType(series: ISeriesItem[]): { [key: string]: ISeriesItem[] } { const primaryType = get(head(series), "type"); return series.reduce( (result: { [key: string]: ISeriesItem[] }, item: ISeriesItem) => { if (primaryType === item.type) { result.primaryItems.push(item); } else { result.secondaryItems.push(item); } return result; }, { primaryItems: [], secondaryItems: [], }, ); } export function getComboChartSeries(series: ISeriesItem[]) { const { primaryItems, secondaryItems } = groupSeriesItemsByType(series); const primaryItem: ISeriesItem = head(primaryItems) || {}; const secondaryItem: ISeriesItem = head(secondaryItems) || {}; const primaryType: string = primaryItem.type || VisualizationTypes.COLUMN; const secondaryType: string = secondaryItem.type || VisualizationTypes.LINE; const [firstAxisKey, secondAxisKey] = LEGEND_TEXT_KEYS.combo; // convert to dual axis series when there is only one chart type if (isEmpty(secondaryItems)) { return transformToDualAxesSeries(series, primaryType); } // all measures display on same axis if (primaryItem.yAxis === secondaryItem.yAxis) { return [ { type: LEGEND_AXIS_INDICATOR, labelKey: primaryType }, ...primaryItems, { type: LEGEND_SEPARATOR }, { type: LEGEND_AXIS_INDICATOR, labelKey: secondaryType }, ...secondaryItems, ]; } return [ { type: LEGEND_AXIS_INDICATOR, labelKey: VisualizationTypes.COMBO, data: [primaryType, firstAxisKey], }, ...primaryItems, { type: LEGEND_SEPARATOR }, { type: LEGEND_AXIS_INDICATOR, labelKey: VisualizationTypes.COMBO, data: [secondaryType, secondAxisKey], }, ...secondaryItems, ]; } export function transformToDualAxesSeries(series: any[], chartType: string) { const { itemsOnFirstAxis, itemsOnSecondAxis } = separateLegendItems(series); if ( !isOneOfTypes(chartType, supportedDualAxesChartTypes) || !itemsOnFirstAxis.length || !itemsOnSecondAxis.length ) { return series; } const [firstAxisKey, secondAxisKey] = LEGEND_TEXT_KEYS[chartType]; return [ { type: LEGEND_AXIS_INDICATOR, labelKey: firstAxisKey }, ...itemsOnFirstAxis, { type: LEGEND_SEPARATOR }, { type: LEGEND_AXIS_INDICATOR, labelKey: secondAxisKey }, ...itemsOnSecondAxis, ]; } export function isStackedChart(chartOptions: any) { const seriesLength = get(chartOptions, "data.series.length"); const { type, stacking, hasStackByAttribute } = chartOptions; const hasMoreThanOneSeries = seriesLength > 1; const isAreaChartWithOneSerie = isAreaChart(type) && !hasMoreThanOneSeries && !hasStackByAttribute; return !isAreaChartWithOneSerie && !isTreemap(type) && Boolean(stacking); }