From 3e092155b778406a075f712a326385125c1368fc Mon Sep 17 00:00:00 2001 From: kgopal Date: Thu, 7 Nov 2024 00:10:31 +0000 Subject: [PATCH] Implement Pinterest delta table tooltip feature on timeseries charts --- superset-frontend/package-lock.json | 22 +- superset-frontend/package.json | 2 + .../src/MixedTimeseries/controlPanel.tsx | 2 + .../src/MixedTimeseries/transformProps.ts | 104 ++++--- .../src/MixedTimeseries/types.ts | 4 +- .../src/Timeseries/Area/controlPanel.tsx | 2 + .../Timeseries/Regular/Line/controlPanel.tsx | 2 + .../src/Timeseries/transformProps.ts | 94 +++--- .../src/Timeseries/types.ts | 4 +- .../src/pinterest-utils/constants.ts | 41 +++ .../src/pinterest-utils/controls.tsx | 54 ++++ .../src/pinterest-utils/tooltip.ts | 287 ++++++++++++++++++ .../src/pinterest-utils/types.ts | 17 ++ 13 files changed, 537 insertions(+), 98 deletions(-) create mode 100644 superset-frontend/plugins/plugin-chart-echarts/src/pinterest-utils/constants.ts create mode 100644 superset-frontend/plugins/plugin-chart-echarts/src/pinterest-utils/controls.tsx create mode 100644 superset-frontend/plugins/plugin-chart-echarts/src/pinterest-utils/tooltip.ts create mode 100644 superset-frontend/plugins/plugin-chart-echarts/src/pinterest-utils/types.ts diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index f0ae0288d3553..001574b811fd0 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -69,6 +69,7 @@ "dom-to-image-more": "^3.2.0", "dom-to-pdf": "^0.3.1", "emotion-rgba": "0.0.12", + "escape-html": "^1.0.3", "fast-glob": "^3.2.7", "fontsource-fira-code": "^4.0.0", "fs-extra": "^10.0.0", @@ -182,6 +183,7 @@ "@types/dom-to-image": "^2.6.7", "@types/enzyme": "^3.10.18", "@types/enzyme-adapter-react-16": "^1.0.6", + "@types/escape-html": "^1.0.4", "@types/fetch-mock": "^7.3.2", "@types/jest": "^26.0.3", "@types/jquery": "^3.5.8", @@ -22341,6 +22343,12 @@ "@types/enzyme": "*" } }, + "node_modules/@types/escape-html": { + "version": "1.0.4", + "resolved": "https://artifacts-prod-use1.pinadmin.com/artifactory/api/npm/node-npm-yarn-prod-virtual/@types/escape-html/-/escape-html-1.0.4.tgz", + "integrity": "sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg==", + "dev": true + }, "node_modules/@types/escodegen": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/escodegen/-/escodegen-0.0.6.tgz", @@ -34092,8 +34100,8 @@ }, "node_modules/escape-html": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + "resolved": "https://artifacts-prod-use1.pinadmin.com/artifactory/api/npm/node-npm-yarn-prod-virtual/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "node_modules/escape-string-regexp": { "version": "1.0.5", @@ -89097,6 +89105,12 @@ "@types/enzyme": "*" } }, + "@types/escape-html": { + "version": "1.0.4", + "resolved": "https://artifacts-prod-use1.pinadmin.com/artifactory/api/npm/node-npm-yarn-prod-virtual/@types/escape-html/-/escape-html-1.0.4.tgz", + "integrity": "sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg==", + "dev": true + }, "@types/escodegen": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/escodegen/-/escodegen-0.0.6.tgz", @@ -98343,8 +98357,8 @@ }, "escape-html": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + "resolved": "https://artifacts-prod-use1.pinadmin.com/artifactory/api/npm/node-npm-yarn-prod-virtual/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "escape-string-regexp": { "version": "1.0.5", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 632c4c98b48a8..b78711cd41225 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -135,6 +135,7 @@ "dom-to-image-more": "^3.2.0", "dom-to-pdf": "^0.3.1", "emotion-rgba": "0.0.12", + "escape-html": "^1.0.3", "fast-glob": "^3.2.7", "fontsource-fira-code": "^4.0.0", "fs-extra": "^10.0.0", @@ -248,6 +249,7 @@ "@types/dom-to-image": "^2.6.7", "@types/enzyme": "^3.10.18", "@types/enzyme-adapter-react-16": "^1.0.6", + "@types/escape-html": "^1.0.4", "@types/fetch-mock": "^7.3.2", "@types/jest": "^26.0.3", "@types/jquery": "^3.5.8", diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx index 5b24c9ab584a1..7cfa84f357f8d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx @@ -40,6 +40,7 @@ import { xAxisBounds, xAxisLabelRotation, } from '../controls'; +import { pinterestCustomConfig } from '../pinterest-utils/controls'; const { area, @@ -448,6 +449,7 @@ const config: ControlPanelConfig = { }, }, ], + ...pinterestCustomConfig, ], }, ], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts index a0aa94f3610fc..c02763f91c33c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -93,6 +93,7 @@ import { getXAxisFormatter, getYAxisFormatter, } from '../utils/formatters'; +import { getDeltaTableTooltipFormatter } from '../pinterest-utils/tooltip'; const getFormatter = ( customFormatters: Record, @@ -201,6 +202,7 @@ export default function transformProps( percentageThreshold, metrics = [], metricsB = [], + pinterestDeltaTable, }: EchartsMixedTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData }; const refs: Refs = {}; @@ -573,59 +575,61 @@ export default function transformProps( ...getDefaultTooltip(refs), show: !inContextMenu, trigger: richTooltip ? 'axis' : 'item', - formatter: (params: any) => { - const xValue: number = richTooltip - ? params[0].value[0] - : params.value[0]; - const forecastValue: any[] = richTooltip ? params : [params]; + formatter: pinterestDeltaTable + ? getDeltaTableTooltipFormatter(chartProps) + : (params: any) => { + const xValue: number = richTooltip + ? params[0].value[0] + : params.value[0]; + const forecastValue: any[] = richTooltip ? params : [params]; - if (richTooltip && tooltipSortByMetric) { - forecastValue.sort((a, b) => b.data[1] - a.data[1]); - } + if (richTooltip && tooltipSortByMetric) { + forecastValue.sort((a, b) => b.data[1] - a.data[1]); + } - const rows: Array = [`${tooltipFormatter(xValue)}`]; - const forecastValues = - extractForecastValuesFromTooltipParams(forecastValue); + const rows: Array = [`${tooltipFormatter(xValue)}`]; + const forecastValues = + extractForecastValuesFromTooltipParams(forecastValue); - Object.keys(forecastValues).forEach(key => { - const value = forecastValues[key]; - // if there are no dimensions, key is a verbose name of a metric, - // otherwise it is a comma separated string where the first part is metric name - let formatterKey; - if (primarySeries.has(key)) { - formatterKey = - groupby.length === 0 ? inverted[key] : labelMap[key]?.[0]; - } else { - formatterKey = - groupbyB.length === 0 ? inverted[key] : labelMapB[key]?.[0]; - } - const tooltipFormatter = getFormatter( - customFormatters, - formatter, - metrics, - formatterKey, - !!contributionMode, - ); - const tooltipFormatterSecondary = getFormatter( - customFormattersSecondary, - formatterSecondary, - metricsB, - formatterKey, - !!contributionMode, - ); - const content = formatForecastTooltipSeries({ - ...value, - seriesName: key, - formatter: primarySeries.has(key) - ? tooltipFormatter - : tooltipFormatterSecondary, - }); - const contentStyle = - key === focusedSeries ? 'font-weight: 700' : 'opacity: 0.7'; - rows.push(`${content}`); - }); - return rows.join('
'); - }, + Object.keys(forecastValues).forEach(key => { + const value = forecastValues[key]; + // if there are no dimensions, key is a verbose name of a metric, + // otherwise it is a comma separated string where the first part is metric name + let formatterKey; + if (primarySeries.has(key)) { + formatterKey = + groupby.length === 0 ? inverted[key] : labelMap[key]?.[0]; + } else { + formatterKey = + groupbyB.length === 0 ? inverted[key] : labelMapB[key]?.[0]; + } + const tooltipFormatter = getFormatter( + customFormatters, + formatter, + metrics, + formatterKey, + !!contributionMode, + ); + const tooltipFormatterSecondary = getFormatter( + customFormattersSecondary, + formatterSecondary, + metricsB, + formatterKey, + !!contributionMode, + ); + const content = formatForecastTooltipSeries({ + ...value, + seriesName: key, + formatter: primarySeries.has(key) + ? tooltipFormatter + : tooltipFormatterSecondary, + }); + const contentStyle = + key === focusedSeries ? 'font-weight: 700' : 'opacity: 0.7'; + rows.push(`${content}`); + }); + return rows.join('
'); + }, }, legend: { ...getLegendProps( diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/types.ts index e79523d176d02..a5e153d8b74e6 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/types.ts @@ -40,6 +40,7 @@ import { DEFAULT_TITLE_FORM_DATA, DEFAULT_FORM_DATA as TIMESERIES_DEFAULTS, } from '../constants'; +import { PinterestFormData } from '../pinterest-utils/types'; export type EchartsMixedTimeseriesFormData = QueryFormData & { annotationLayers: AnnotationLayer[]; @@ -88,7 +89,8 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & { groupby: QueryFormColumn[]; groupbyB: QueryFormColumn[]; } & LegendFormData & - TitleFormData; + TitleFormData & + PinterestFormData; // @ts-ignore export const DEFAULT_FORM_DATA: EchartsMixedTimeseriesFormData = { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx index 79cd92a504a2c..aed826a9f10ce 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx @@ -43,6 +43,7 @@ import { minorTicks, } from '../../controls'; import { AreaChartStackControlOptions } from '../../constants'; +import { pinterestCustomConfig } from '../../pinterest-utils/controls'; const { logAxis, @@ -259,6 +260,7 @@ const config: ControlPanelConfig = { }, }, ], + ...pinterestCustomConfig, ], }, ], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx index f6f3fb8e8e4f0..b6988e509e3c1 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx @@ -28,6 +28,7 @@ import { sharedControls, } from '@superset-ui/chart-controls'; +import { pinterestCustomConfig } from '../../../pinterest-utils/controls'; import { EchartsTimeseriesSeriesType } from '../../types'; import { DEFAULT_FORM_DATA, @@ -247,6 +248,7 @@ const config: ControlPanelConfig = { }, }, ], + ...pinterestCustomConfig, ], }, ], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts index 3c12de9a3a639..7d2a1b7c5186e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -101,6 +101,7 @@ import { getXAxisFormatter, getYAxisFormatter, } from '../utils/formatters'; +import { getDeltaTableTooltipFormatter } from '../pinterest-utils/tooltip'; export default function transformProps( chartProps: EchartsTimeseriesChartProps, @@ -183,6 +184,7 @@ export default function transformProps( yAxisTitleMargin, yAxisTitlePosition, zoomable, + pinterestDeltaTable, }: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData }; const refs: Refs = {}; const groupBy = ensureIsArray(groupby); @@ -527,48 +529,56 @@ export default function transformProps( ...getDefaultTooltip(refs), show: !inContextMenu, trigger: richTooltip ? 'axis' : 'item', - formatter: (params: any) => { - const [xIndex, yIndex] = isHorizontal ? [1, 0] : [0, 1]; - const xValue: number = richTooltip - ? params[0].value[xIndex] - : params.value[xIndex]; - const forecastValue: any[] = richTooltip ? params : [params]; - - if (richTooltip && tooltipSortByMetric) { - forecastValue.sort((a, b) => b.data[yIndex] - a.data[yIndex]); - } - - const rows: string[] = []; - const forecastValues: Record = - extractForecastValuesFromTooltipParams(forecastValue, isHorizontal); - - Object.keys(forecastValues).forEach(key => { - const value = forecastValues[key]; - if (value.observation === 0 && stack) { - return; - } - // if there are no dimensions, key is a verbose name of a metric, - // otherwise it is a comma separated string where the first part is metric name - const formatterKey = - groupBy.length === 0 ? inverted[key] : labelMap[key]?.[0]; - const content = formatForecastTooltipSeries({ - ...value, - seriesName: key, - formatter: forcePercentFormatter - ? percentFormatter - : getCustomFormatter(customFormatters, metrics, formatterKey) ?? - defaultFormatter, - }); - const contentStyle = - key === focusedSeries ? 'font-weight: 700' : 'opacity: 0.7'; - rows.push(`${content}`); - }); - if (stack) { - rows.reverse(); - } - rows.unshift(`${tooltipFormatter(xValue)}`); - return rows.join('
'); - }, + formatter: pinterestDeltaTable + ? getDeltaTableTooltipFormatter(chartProps) + : (params: any) => { + const [xIndex, yIndex] = isHorizontal ? [1, 0] : [0, 1]; + const xValue: number = richTooltip + ? params[0].value[xIndex] + : params.value[xIndex]; + const forecastValue: any[] = richTooltip ? params : [params]; + + if (richTooltip && tooltipSortByMetric) { + forecastValue.sort((a, b) => b.data[yIndex] - a.data[yIndex]); + } + + const rows: string[] = []; + const forecastValues: Record = + extractForecastValuesFromTooltipParams( + forecastValue, + isHorizontal, + ); + + Object.keys(forecastValues).forEach(key => { + const value = forecastValues[key]; + if (value.observation === 0 && stack) { + return; + } + // if there are no dimensions, key is a verbose name of a metric, + // otherwise it is a comma separated string where the first part is metric name + const formatterKey = + groupBy.length === 0 ? inverted[key] : labelMap[key]?.[0]; + const content = formatForecastTooltipSeries({ + ...value, + seriesName: key, + formatter: forcePercentFormatter + ? percentFormatter + : getCustomFormatter( + customFormatters, + metrics, + formatterKey, + ) ?? defaultFormatter, + }); + const contentStyle = + key === focusedSeries ? 'font-weight: 700' : 'opacity: 0.7'; + rows.push(`${content}`); + }); + if (stack) { + rows.reverse(); + } + rows.unshift(`${tooltipFormatter(xValue)}`); + return rows.join('
'); + }, }, legend: { ...getLegendProps( diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts index 6ca9650db62ef..95c1fb789b4b9 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts @@ -35,6 +35,7 @@ import { StackType, TitleFormData, } from '../types'; +import { PinterestFormData } from '../pinterest-utils/types'; export enum OrientationType { Vertical = 'vertical', @@ -92,7 +93,8 @@ export type EchartsTimeseriesFormData = QueryFormData & { percentageThreshold: number; orientation?: OrientationType; } & LegendFormData & - TitleFormData; + TitleFormData & + PinterestFormData; export interface EchartsTimeseriesChartProps extends BaseChartProps { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/pinterest-utils/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/pinterest-utils/constants.ts new file mode 100644 index 0000000000000..0a79414001bb3 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/pinterest-utils/constants.ts @@ -0,0 +1,41 @@ +import { DeltaDirection, DeltaTableColumn } from './types'; + +export const TIME_OFFSET_BY_COLUMN = { + [DeltaTableColumn.DayOverDay]: 1, + [DeltaTableColumn.WeekOverWeek]: 7, + [DeltaTableColumn.MonthOverMonth]: 28, + [DeltaTableColumn.YearOverYear]: 365, +} as Record; + +export const DELTA_TABLE_COLUMNS = [ + DeltaTableColumn.Metric, + DeltaTableColumn.Value, + DeltaTableColumn.DayOverDay, + DeltaTableColumn.WeekOverWeek, + DeltaTableColumn.MonthOverMonth, + DeltaTableColumn.YearOverYear, +]; + +export const PERCENT_CHANGE_COLUMNS = [ + DeltaTableColumn.DayOverDay, + DeltaTableColumn.WeekOverWeek, + DeltaTableColumn.MonthOverMonth, + DeltaTableColumn.YearOverYear, +]; + +export const MILLISECONDS_IN_DAY = 1000 * 60 * 60 * 24; + +export const DIRECTION_SYMBOL = { + [DeltaDirection.Up]: '↑', + [DeltaDirection.Down]: '↓', +}; + +export const PINTEREST_DEFAULT_FORM_DATA = { + pinterestDeltaTable: false, + pinterestDeltaTableColumns: [ + DeltaTableColumn.DayOverDay, + DeltaTableColumn.WeekOverWeek, + DeltaTableColumn.MonthOverMonth, + DeltaTableColumn.YearOverYear, + ], +}; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/pinterest-utils/controls.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/pinterest-utils/controls.tsx new file mode 100644 index 0000000000000..8743c5ef33015 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/pinterest-utils/controls.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { t, validateNonEmpty } from '@superset-ui/core'; +import { + formatSelectOptions, + ControlSetRow, + ControlSubSectionHeader, + ControlPanelsContainerProps, +} from '@superset-ui/chart-controls'; +import { DeltaTableColumn } from './types'; +import { PINTEREST_DEFAULT_FORM_DATA } from './constants'; + +export const pinterestCustomConfig: ControlSetRow[] = [ + [Pinterest Settings], + [ + { + name: 'pinterestDeltaTable', + config: { + type: 'CheckboxControl', + label: 'Pinterest Delta Table', + default: PINTEREST_DEFAULT_FORM_DATA.pinterestDeltaTable, + renderTrigger: true, + description: t( + 'Show a rich tooltip with time comparisons of metrics (similar to the Pinalytics delta table)', + ), + }, + }, + ], + [ + { + name: 'pinterestDeltaTableColumns', + config: { + type: 'SelectControl', + freeForm: false, + clearable: false, + multi: true, + label: t('Delta columns'), + choices: formatSelectOptions([ + DeltaTableColumn.DayOverDay, + DeltaTableColumn.WeekOverWeek, + DeltaTableColumn.MonthOverMonth, + DeltaTableColumn.YearOverYear, + ]), + default: PINTEREST_DEFAULT_FORM_DATA.pinterestDeltaTableColumns, + renderTrigger: true, + description: t( + 'Delta columns to show on the rich tooltip (only shows delta columns if there is enough data to compute value)', + ), + validators: [validateNonEmpty], + visibility: ({ controls }: ControlPanelsContainerProps) => + Boolean(controls?.pinterestDeltaTable?.value), + }, + }, + ], +]; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/pinterest-utils/tooltip.ts b/superset-frontend/plugins/plugin-chart-echarts/src/pinterest-utils/tooltip.ts new file mode 100644 index 0000000000000..801c32d84a9c3 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/pinterest-utils/tooltip.ts @@ -0,0 +1,287 @@ +import { + DTTM_ALIAS, + GenericDataType, + getColumnLabel, + SupersetTheme, + TimeFormatter, + TimeseriesChartDataResponseResult, + TimeseriesDataRecord, +} from '@superset-ui/core'; +import { orderBy } from 'lodash'; +import { + CallbackDataParams, + TooltipPositionCallbackParams, +} from 'echarts/types/src/util/types'; +import escape from 'escape-html'; +import { getColtypesMapping } from '../utils/series'; +import { DEFAULT_FORM_DATA } from '../Timeseries/constants'; +import { + EchartsTimeseriesChartProps, + EchartsTimeseriesFormData, + OrientationType, +} from '../types'; +import { EchartsMixedTimeseriesProps } from '../MixedTimeseries/types'; +import { + DELTA_TABLE_COLUMNS, + DIRECTION_SYMBOL, + MILLISECONDS_IN_DAY, + PERCENT_CHANGE_COLUMNS, + TIME_OFFSET_BY_COLUMN, +} from './constants'; +import { getTooltipTimeFormatter } from '../utils/formatters'; +import { DeltaDirection, DeltaTableColumn } from './types'; + +const getPreviousDate = (date: Date, offsetDays: number) => { + const previousDate = new Date(date); + previousDate.setDate(date.getDate() - offsetDays); + return previousDate; +}; + +export const getDateByTimeDelta = { + [DeltaTableColumn.DayOverDay]: (date: Date) => getPreviousDate(date, 1), + [DeltaTableColumn.WeekOverWeek]: (date: Date) => getPreviousDate(date, 7), + [DeltaTableColumn.MonthOverMonth]: (date: Date) => getPreviousDate(date, 28), + [DeltaTableColumn.YearOverYear]: (date: Date) => { + const previousDate = new Date(date); + previousDate.setFullYear(date.getFullYear() - 1); + return previousDate; + }, +} as Record Date>; + +class DeltaTableTooltipFormatter { + formData: EchartsTimeseriesFormData; + + dataByTimestamp: Record; + + deltaTableColumns: DeltaTableColumn[]; + + columnNameByVerboseName: Record; + + timeFormatter: TimeFormatter | StringConstructor; + + theme: SupersetTheme; + + constructor( + chartProps: EchartsTimeseriesChartProps | EchartsMixedTimeseriesProps, + ) { + const { datasource, queriesData, theme } = chartProps; + this.theme = theme; + + const formData = { + ...DEFAULT_FORM_DATA, + ...chartProps.formData, + }; + this.formData = formData; + const { xAxis: xAxisOrig, tooltipTimeFormat } = formData; + + const { verboseMap = {} } = datasource; + const xAxisColName = + verboseMap[xAxisOrig] || getColumnLabel(xAxisOrig || DTTM_ALIAS); + + const [queryData] = queriesData; + this.dataByTimestamp = {} as Record; + queriesData.forEach(queryData => { + const { data = [] } = queryData as TimeseriesChartDataResponseResult; + this.dataByTimestamp = data.reduce((accum, curr) => { + const timestamp = (curr[xAxisColName] as Date).valueOf(); + if (!(timestamp in accum)) { + // eslint-disable-next-line no-param-reassign + accum[timestamp] = curr; + } + // Merge data from multiple queries + Object.assign(accum[timestamp], curr); + return accum; + }, this.dataByTimestamp); + }); + this.deltaTableColumns = this.getDeltaTableColumns(); + this.columnNameByVerboseName = Object.entries(verboseMap).reduce( + (accum, [columnName, verboseName]) => { + // eslint-disable-next-line no-param-reassign + accum[verboseName] = columnName; + return accum; + }, + {} as Record, + ); + + const dataTypes = getColtypesMapping(queryData); + const xAxisDataType = dataTypes?.[xAxisColName] ?? dataTypes?.[xAxisOrig]; + this.timeFormatter = + xAxisDataType === GenericDataType.Temporal + ? getTooltipTimeFormatter(tooltipTimeFormat) + : String; + } + + getDeltaTableColumns() { + const allTimestamps = Object.keys(this.dataByTimestamp).map(date => +date); + const firstTimestamp = Math.min(...allTimestamps); + const lastTimestamp = Math.max(...allTimestamps); + const dataTimeRange = + (lastTimestamp - firstTimestamp) / MILLISECONDS_IN_DAY; + return DELTA_TABLE_COLUMNS.filter( + col => + !PERCENT_CHANGE_COLUMNS.includes(col) || + TIME_OFFSET_BY_COLUMN[col] <= dataTimeRange, + ); + } + + getCellStyle(column: string, color?: string) { + const textAlign = column === DeltaTableColumn.Metric ? 'left' : 'right'; + let style = `padding:5px;text-align:${textAlign};`; + if (color) { + style += `color:${color};`; + } + return style; + } + + getDataColumn = (seriesName: string) => { + const sampleChartData = Object.values(this.dataByTimestamp)[0]; + if (!(seriesName in sampleChartData)) { + return this.columnNameByVerboseName[seriesName]; + } + return seriesName; + }; + + getDeltaTableData = ( + timestamp: number, + seriesName: string, + overrideDeltaTableColumns?: Array, + ) => { + const deltaTableColumns = + overrideDeltaTableColumns ?? this.deltaTableColumns; + const columnName = this.getDataColumn(seriesName); + const currentValue = this.dataByTimestamp[timestamp][columnName]; + const currentDate = new Date(timestamp); + + const getDataPercentChange = (previousDate: Date) => { + const originalTimestamp = previousDate.valueOf(); + if (!(originalTimestamp in this.dataByTimestamp)) { + return null; + } + const originalValue = this.dataByTimestamp[originalTimestamp][columnName]; + if (currentValue == null || !originalValue) { + // Check to not divide by zero or use null values + return null; + } + const proportionalChange = + ((currentValue as number) - (originalValue as number)) / + (originalValue as number); + const percentChange = proportionalChange * 100; + return Number(percentChange.toFixed(2)); + }; + + const percentChangeByKey = deltaTableColumns.reduce( + (accum, column) => { + if (PERCENT_CHANGE_COLUMNS.includes(column)) { + const previousDate = getDateByTimeDelta[column](currentDate); + // eslint-disable-next-line no-param-reassign + accum[column] = getDataPercentChange(previousDate); + } + return accum; + }, + {} as Record, + ); + + return { + ...percentChangeByKey, + [DeltaTableColumn.Metric]: seriesName, + [DeltaTableColumn.Value]: (currentValue ?? 'null').toLocaleString(), + }; + }; + + getDeltaTableRows(params: CallbackDataParams[], xIndex: number) { + const { pinterestDeltaTableColumns } = this.formData; + const deltaTableColumns = this.deltaTableColumns.filter( + column => + !PERCENT_CHANGE_COLUMNS.includes(column) || + pinterestDeltaTableColumns.includes(column), + ); + const rows = [ + deltaTableColumns.map(column => ({ + element: 'th', + style: this.getCellStyle(column), + data: column, + })), + ] as Array< + Array<{ element: string; style: string; data: string | number }> + >; + params.forEach(param => { + const deltaTableData = this.getDeltaTableData( + (param.value as number[])[xIndex], + param.seriesName!, + deltaTableColumns, + ); + const newRow = deltaTableColumns.map(column => { + const columnData = deltaTableData[column]; + let color; + let data = columnData ?? '-'; + if (column === DeltaTableColumn.Metric) { + data = param.marker + escape(columnData?.toString()); + } else if ( + PERCENT_CHANGE_COLUMNS.includes(column) && + columnData != null + ) { + data += '%'; + if ((columnData as number) > 0) { + color = this.theme.colors.success.dark1; + data += DIRECTION_SYMBOL[DeltaDirection.Up]; + } else if ((columnData as number) < 0) { + color = this.theme.colors.error.dark1; + data += DIRECTION_SYMBOL[DeltaDirection.Down]; + } + } + return { + element: 'td', + style: this.getCellStyle(column, color), + data, + }; + }); + rows.push(newRow); + }); + return rows; + } + + getTooltipFormatter() { + const { richTooltip, tooltipSortByMetric, orientation } = this.formData; + + const [xIndex, yIndex] = + orientation === OrientationType.Horizontal ? [1, 0] : [0, 1]; + + return (initialParams: TooltipPositionCallbackParams) => { + let params: CallbackDataParams[] = richTooltip + ? (initialParams as CallbackDataParams[]) + : [initialParams as CallbackDataParams]; + if (tooltipSortByMetric) { + params = orderBy(params, [ + ({ value }: CallbackDataParams) => -1 * (value as number[])[yIndex], + ['desc'], + ]) as CallbackDataParams[]; + } + const deltaTableRows = this.getDeltaTableRows(params, xIndex); + const xValue = (params[0].value as number[])[xIndex]; + + return ` + ${this.timeFormatter(xValue)} +
+ + ${deltaTableRows + .map( + columns => + `${columns + .map( + ({ element, style, data }) => + `<${element} style=${style}>${data}`, + ) + .join('')}`, + ) + .join('')} +
`; + }; + } +} + +export const getDeltaTableTooltipFormatter = ( + chartProps: EchartsTimeseriesChartProps | EchartsMixedTimeseriesProps, +) => { + const tooltipFormatter = new DeltaTableTooltipFormatter(chartProps); + return tooltipFormatter.getTooltipFormatter(); +}; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/pinterest-utils/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/pinterest-utils/types.ts new file mode 100644 index 0000000000000..60652a0f96d16 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/pinterest-utils/types.ts @@ -0,0 +1,17 @@ +export enum DeltaTableColumn { + Metric = 'Metric', + Value = 'Value', + DayOverDay = 'D/D', + WeekOverWeek = 'W/W', + MonthOverMonth = 'M/M', + YearOverYear = 'Y/Y', +} +export enum DeltaDirection { + Up = 'Up', + Down = 'Down', +} + +export type PinterestFormData = { + pinterestDeltaTable: boolean; + pinterestDeltaTableColumns: DeltaTableColumn[]; +};