@gooddata/react-components
Version:
GoodData.UI - A powerful JavaScript library for building analytical applications
598 lines (533 loc) • 21.9 kB
text/typescript
// (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);
}