@gooddata/react-components
Version:
GoodData.UI - A powerful JavaScript library for building analytical applications
523 lines (467 loc) • 16.6 kB
text/typescript
// (C) 2007-2020 GoodData Corporation
import get = require("lodash/get");
import debounce = require("lodash/debounce");
import omit = require("lodash/omit");
import without = require("lodash/without");
import * as CustomEvent from "custom-event";
import * as invariant from "invariant";
import Highcharts from "../chart/highcharts/highchartsEntryPoint";
import { IUnwrappedAttributeHeaderWithItems } from "../../visualizations/typings/chart";
import {
ChartElementType,
ChartType,
VisType,
VisualizationTypes,
} from "../../../constants/visualizationTypes";
import {
IDrillEvent,
IDrillEventIntersectionElement,
IDrillEventContextTable,
IHighchartsPointObject,
IDrillConfig,
ICellDrillEvent,
isGroupHighchartsDrillEvent,
IDrillEventContext,
IDrillEventIntersectionElementExtended,
IDrillEventContextExtended,
IDrillEventExtended,
IDrillPointExtended,
IDrillEventContextGroupExtended,
IDrillPointBase,
IDrillEventContextPointExtended,
IDrillEventContextPointBase,
IGeoDrillEvent,
} from "../../../interfaces/DrillEvents";
import { OnFiredDrillEvent } from "../../../interfaces/Events";
import { IGeoData } from "../../../interfaces/GeoChart";
import { IHeaderPredicate } from "../../../interfaces/HeaderPredicate";
import { isComboChart, isHeatmap, isTreemap, isBulletChart } from "./common";
import { findMeasureGroupInDimensions } from "../../../helpers/executionResultHelper";
import { findGeoAttributesInDimension } from "../../../helpers/geoChart/executionResultHelper";
import { parseGeoProperties } from "../../../helpers/geoChart/data";
import { isSomeHeaderPredicateMatched } from "../../../helpers/headerPredicate";
import { getVisualizationType } from "../../../helpers/visualizationType";
import { AFM, Execution } from "@gooddata/typings";
import {
IMappingHeader,
isMappingHeaderAttribute,
isMappingHeaderAttributeItem,
isMappingHeaderMeasureItem,
} from "../../../interfaces/MappingHeader";
import { convertDrillContextToLegacy } from "./drilldownEventingLegacy";
export function getClickableElementNameByChartType(type: VisType): ChartElementType {
switch (type) {
case VisualizationTypes.LINE:
case VisualizationTypes.AREA:
case VisualizationTypes.SCATTER:
case VisualizationTypes.BUBBLE:
return "point";
case VisualizationTypes.COLUMN:
case VisualizationTypes.BAR:
return "bar";
case VisualizationTypes.PIE:
case VisualizationTypes.TREEMAP:
case VisualizationTypes.DONUT:
case VisualizationTypes.FUNNEL:
return "slice";
case VisualizationTypes.HEATMAP:
return "cell";
default:
invariant(false, `Unknown visualization type: ${type}`);
return null;
}
}
export function fireDrillEvent(onFiredDrillEvent: OnFiredDrillEvent, data: any, target: EventTarget) {
const returnValue = onFiredDrillEvent && onFiredDrillEvent(data);
// if user-specified onFiredDrillEvent fn returns false, do not fire default DOM event
if (returnValue !== false) {
const event = new CustomEvent("drill", {
detail: data,
bubbles: true,
});
target.dispatchEvent(event);
}
}
const getElementChartType = (chartType: ChartType, point: IHighchartsPointObject): ChartType => {
return get(point, "series.type", chartType);
};
const getDrillPointCustomProps = (
point: IHighchartsPointObject,
chartType: ChartType,
): Partial<IDrillPointBase> => {
if (isComboChart(chartType)) {
return { type: get(point, "series.type") };
}
if (isBulletChart(chartType)) {
return { type: get(point, "series.userOptions.bulletChartMeasureType") };
}
return {};
};
const getYValueForBulletChartTarget = (point: IHighchartsPointObject): number => {
if (point.isNullTarget) {
return null;
}
return point.target;
};
const getDrillPoint = (chartType: ChartType) => (point: IHighchartsPointObject): IDrillPointExtended => {
const customProps = getDrillPointCustomProps(point, chartType);
const elementChartType = getElementChartType(chartType, point);
const result: IDrillPointExtended = {
x: point.x,
y: elementChartType === "bullet" ? getYValueForBulletChartTarget(point) : point.y,
intersection: point.drillIntersection,
...customProps,
};
return result;
};
function composeDrillContextGroup(
points: IHighchartsPointObject[],
chartType: ChartType,
): IDrillEventContextGroupExtended {
const sanitizedPoints = sanitizeContextPoints(chartType, points);
const contextPoints: IDrillPointExtended[] = sanitizedPoints.map(getDrillPoint(chartType));
return {
type: chartType,
element: "label",
points: contextPoints,
};
}
function getClickableElementNameForBulletChart(point: any) {
return point.series.userOptions.bulletChartMeasureType;
}
function composeDrillContextPoint(
point: IHighchartsPointObject,
chartType: ChartType,
): IDrillEventContextPointExtended {
const zProp = isNaN(point.z) ? {} : { z: point.z };
const valueProp =
isTreemap(chartType) || isHeatmap(chartType)
? {
value: point.value ? point.value.toString() : "",
}
: {};
const elementChartType = getElementChartType(chartType, point);
const xyProp = isTreemap(chartType)
? {}
: {
x: point.x,
y: elementChartType === "bullet" ? point.target : point.y,
};
const customProp: Partial<IDrillEventContextPointBase> = isComboChart(chartType)
? {
elementChartType,
}
: {};
return {
type: chartType,
element: isBulletChart(chartType)
? getClickableElementNameForBulletChart(point)
: getClickableElementNameByChartType(elementChartType),
intersection: point.drillIntersection,
...xyProp,
...zProp,
...valueProp,
...customProp,
};
}
const chartClickDebounced = debounce(
(
drillConfig: IDrillConfig,
event: Highcharts.DrilldownEventObject,
target: EventTarget,
chartType: ChartType,
) => {
const { afm, onFiredDrillEvent, onDrill } = drillConfig;
const type = getVisualizationType(chartType);
let drillContext: IDrillEventContextExtended = null;
if (isGroupHighchartsDrillEvent(event)) {
const points = event.points as IHighchartsPointObject[];
drillContext = composeDrillContextGroup(points, type);
} else {
const point: IHighchartsPointObject = event.point as IHighchartsPointObject;
drillContext = composeDrillContextPoint(point, type);
}
const drillEventExtended: IDrillEventExtended = {
executionContext: afm,
drillContext,
};
if (onDrill) {
onDrill(drillEventExtended);
}
const drillContextLegacy: IDrillEventContext = convertDrillContextToLegacy(drillContext, afm);
const drillEventLegacy: IDrillEvent = {
executionContext: afm,
drillContext: drillContextLegacy,
};
fireDrillEvent(onFiredDrillEvent, drillEventLegacy, target);
},
);
export function chartClick(
drillConfig: IDrillConfig,
event: Highcharts.DrilldownEventObject,
target: EventTarget,
chartType: ChartType,
) {
chartClickDebounced(drillConfig, event, target, chartType);
}
const getDrillEvent = (
points: IHighchartsPointObject[],
chartType: ChartType,
afm: AFM.IAfm,
): IDrillEventExtended => {
const contextPoints: IDrillPointExtended[] = points.map((point: IHighchartsPointObject) => {
const customProps = isBulletChart(chartType)
? { type: get(point, "series.userOptions.bulletChartMeasureType") }
: {};
return {
x: point.x,
y: point.y,
intersection: point.drillIntersection,
...customProps,
};
});
const drillContext: IDrillEventContextExtended = {
type: chartType,
element: "label",
points: contextPoints,
};
return {
executionContext: afm,
drillContext,
};
};
const tickLabelClickDebounce = debounce(
(
drillConfig: IDrillConfig,
points: IHighchartsPointObject[],
target: EventTarget,
chartType: ChartType,
): void => {
const { afm, onFiredDrillEvent, onDrill } = drillConfig;
const sanitizedPoints = sanitizeContextPoints(chartType, points);
const dataExtended: IDrillEventExtended = getDrillEvent(sanitizedPoints, chartType, afm);
if (onDrill) {
onDrill(dataExtended);
}
const drillContext: IDrillEventContext = convertDrillContextToLegacy(dataExtended.drillContext, afm);
const data: IDrillEvent = {
executionContext: afm,
drillContext,
};
fireDrillEvent(onFiredDrillEvent, data, target);
},
);
function sanitizeContextPoints(
chartType: ChartType,
points: IHighchartsPointObject[],
): IHighchartsPointObject[] {
if (isHeatmap(chartType)) {
return points.filter((point: IHighchartsPointObject) => !point.ignoredInDrillEventContext);
}
return points;
}
export function tickLabelClick(
drillConfig: IDrillConfig,
points: IHighchartsPointObject[],
target: EventTarget,
chartType: ChartType,
) {
tickLabelClickDebounce(drillConfig, points, target, chartType);
}
export function cellClick(drillConfig: IDrillConfig, event: ICellDrillEvent, target: EventTarget) {
const { afm, onFiredDrillEvent } = drillConfig;
const { columnIndex, rowIndex, row, intersection } = event;
const drillContext: IDrillEventContextTable = {
type: VisualizationTypes.TABLE,
element: "cell",
columnIndex,
rowIndex,
row,
intersection,
};
const data: IDrillEvent = {
executionContext: afm,
drillContext,
};
fireDrillEvent(onFiredDrillEvent, data, target);
}
export function createDrillIntersectionElement(
id: string,
title: string,
uri?: string,
identifier?: string,
): IDrillEventIntersectionElement {
const element: IDrillEventIntersectionElement = {
id: id || "",
title: title || "",
};
if (uri || identifier) {
element.header = {
uri: uri || "",
identifier: identifier || "",
};
}
return element;
}
// shared by charts and table
export const getDrillIntersection = (
drillItems: IMappingHeader[],
): IDrillEventIntersectionElementExtended[] => {
return drillItems.reduce(
(
drillIntersection: IDrillEventIntersectionElementExtended[],
drillItem: IMappingHeader,
index: number,
drillItems: IMappingHeader[],
): IDrillEventIntersectionElementExtended[] => {
if (isMappingHeaderAttribute(drillItem)) {
const attributeItem = drillItems[index - 1]; // attribute item is always before attribute
if (attributeItem && isMappingHeaderAttributeItem(attributeItem)) {
drillIntersection.push({
header: {
...attributeItem,
...drillItem,
},
});
} else {
// no attr. item before attribute -> use only attribute header
drillIntersection.push({
header: drillItem,
});
}
} else if (isMappingHeaderMeasureItem(drillItem)) {
drillIntersection.push({
header: drillItem,
});
}
return drillIntersection;
},
[],
);
};
function getDrillIntersectionForGeoChart(
drillableItems: IHeaderPredicate[],
drillConfig: IDrillConfig,
execution: Execution.IExecutionResponses,
geoData: IGeoData,
locationIndex: number,
): IDrillEventIntersectionElementExtended[] {
const { executionResponse } = execution;
const { dimensions } = executionResponse;
const { items: measureGroupItems = [] } = findMeasureGroupInDimensions(dimensions) || {};
const measureHeaders: Execution.IMeasureHeaderItem[] = measureGroupItems.slice(0, 2);
const { locationAttribute, segmentByAttribute, tooltipTextAttribute } = findGeoAttributesInDimension(
execution,
geoData,
);
const {
attributeHeader: locationAttributeHeader,
attributeHeaderItem: locationAttributeHeaderItem,
} = getAttributeHeader(locationAttribute, locationIndex);
const {
attributeHeader: segmentByAttributeHeader,
attributeHeaderItem: segmentByAttributeHeaderItem,
} = getAttributeHeader(segmentByAttribute, locationIndex);
const {
attributeHeader: tooltipTextAttributeHeader,
attributeHeaderItem: tooltipTextAttributeHeaderItem,
} = getAttributeHeader(tooltipTextAttribute, locationIndex);
// pin is drillable if a drillableItem matches:
// pin's measure,
// pin's location attribute,
// pin's location attribute item,
// pin's segmentBy attribute,
// pin's segmentBy attribute item,
// pin's tooltipText attribute,
// pin's tooltipText attribute item,
const drillItems: IMappingHeader[] = without(
[
...measureHeaders,
locationAttributeHeaderItem,
locationAttributeHeader,
segmentByAttributeHeaderItem,
segmentByAttributeHeader,
tooltipTextAttributeHeaderItem,
tooltipTextAttributeHeader,
],
undefined,
);
const drilldown: boolean = drillItems.some(
(drillableHook: IMappingHeader): boolean =>
isSomeHeaderPredicateMatched(drillableItems, drillableHook, drillConfig.afm, executionResponse),
);
if (drilldown) {
return getDrillIntersection(drillItems);
}
return undefined;
}
function getAttributeHeader(
attribute: IUnwrappedAttributeHeaderWithItems,
dataIndex: number,
): {
attributeHeader: Execution.IAttributeHeader;
attributeHeaderItem: Execution.IResultAttributeHeaderItem;
} {
if (attribute) {
return {
attributeHeader: { attributeHeader: omit(attribute, "items") },
attributeHeaderItem: attribute.items[dataIndex],
};
}
return {
attributeHeader: undefined,
attributeHeaderItem: undefined,
};
}
export function handleGeoPushpinDrillEvent(
drillableItems: IHeaderPredicate[],
drillConfig: IDrillConfig,
execution: Execution.IExecutionResponses,
geoData: IGeoData,
pinProperties: GeoJSON.GeoJsonProperties,
pinCoordinates: number[],
target: EventTarget,
): void {
const { locationIndex } = pinProperties;
const drillIntersection: IDrillEventIntersectionElementExtended[] = getDrillIntersectionForGeoChart(
drillableItems,
drillConfig,
execution,
geoData,
locationIndex,
);
if (!drillIntersection || !drillIntersection.length) {
return;
}
const { afm, onDrill, onFiredDrillEvent } = drillConfig;
const [lng, lat] = pinCoordinates;
const {
locationName: { value: locationNameValue },
color: { value: colorValue },
segment: { value: segmentByValue },
size: { value: sizeValue },
} = parseGeoProperties(pinProperties);
const drillContext: IGeoDrillEvent = {
element: "pushpin",
intersection: drillIntersection,
type: "pushpin",
color: colorValue,
location: { lat, lng },
locationName: locationNameValue,
segmentBy: segmentByValue,
size: sizeValue,
};
const drillEventExtended: IDrillEventExtended = {
executionContext: afm,
drillContext,
};
if (onDrill) {
onDrill(drillEventExtended);
}
const drillContextLegacy: IDrillEventContext = convertDrillContextToLegacy(drillContext, afm);
const drillEventLegacy: IDrillEvent = {
executionContext: afm,
drillContext: drillContextLegacy,
};
fireDrillEvent(onFiredDrillEvent, drillEventLegacy, target);
}