diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/EchartsTimePivot.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/EchartsTimePivot.tsx new file mode 100644 index 00000000000..aacfb5b256d --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/EchartsTimePivot.tsx @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import Echart from '../components/Echart'; +import { allEventHandlers } from '../utils/eventHandlers'; +import { TimePivotChartTransformedProps } from './types'; + +export default function EchartsTimePivot( + props: TimePivotChartTransformedProps, +) { + const { height, width, echartOptions, refs, formData } = props; + + const eventHandlers = allEventHandlers(props); + + return ( + + ); +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/buildQuery.ts new file mode 100644 index 00000000000..76a788b6e69 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/buildQuery.ts @@ -0,0 +1,34 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { buildQueryContext, QueryFormData } from '@superset-ui/core'; + +/** + * Mirrors the legacy NVD3TimePivotViz.query_obj: a timeseries query with + * the single metric. + */ +export default function buildQuery(formData: QueryFormData) { + const { metric } = formData; + return buildQueryContext(formData, baseQueryObject => [ + { + ...baseQueryObject, + is_timeseries: true, + metrics: metric ? [metric] : [], + }, + ]); +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/controlPanel.ts b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/controlPanel.ts new file mode 100644 index 00000000000..6b5e045783b --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/controlPanel.ts @@ -0,0 +1,210 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { t } from '@apache-superset/core/translation'; +import { + ControlPanelConfig, + D3_TIME_FORMAT_OPTIONS, + sections, +} from '@superset-ui/chart-controls'; + +// Control names match the legacy nvd3 chart so saved charts keep working +// without a form-data migration. nvd3-specific pixel-margin and min/max +// toggles are intentionally dropped; ECharts lays those out automatically. +const config: ControlPanelConfig = { + controlPanelSections: [ + sections.legacyTimeseriesTime, + { + label: t('Query'), + expanded: true, + controlSetRows: [ + ['metric'], + ['adhoc_filters'], + [ + { + name: 'freq', + config: { + type: 'SelectControl', + label: t('Frequency'), + default: 'W-MON', + freeForm: true, + clearable: false, + choices: [ + ['AS', t('Year (freq=AS)')], + ['52W-MON', t('52 weeks starting Monday (freq=52W-MON)')], + ['W-SUN', t('1 week starting Sunday (freq=W-SUN)')], + ['W-MON', t('1 week starting Monday (freq=W-MON)')], + ['D', t('Day (freq=D)')], + ['4W-MON', t('4 weeks (freq=4W-MON)')], + ], + description: t( + `The periodicity over which to pivot time. Each period becomes + its own overlaid series, so pick a Time Grain finer than this + frequency to see lines (e.g. Day grain with a weekly + frequency); with one data point per period each series renders + as a single dot. Users can provide "Pandas" offset aliases. + Click on the info bubble for more details on accepted "freq" + expressions.`, + ), + tooltipOnClick: () => { + window.open( + 'https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#offset-aliases', + '_blank', + 'noopener noreferrer', + ); + }, + }, + }, + ], + [ + { + name: 'period_limit', + config: { + type: 'TextControl', + isInt: true, + label: t('Number of periods'), + renderTrigger: true, + default: '', + description: t( + 'Show only the N most recent periods, from 1 up to the number ' + + 'of periods in the data. Leave empty to overlay them all.', + ), + }, + }, + ], + ], + }, + { + label: t('Chart Options'), + expanded: true, + controlSetRows: [ + [ + { + name: 'show_legend', + config: { + type: 'CheckboxControl', + label: t('Legend'), + renderTrigger: true, + default: false, + description: t('Whether to display the legend (toggles)'), + }, + }, + ], + ['color_picker'], + [ + { + name: 'line_interpolation', + config: { + type: 'SelectControl', + label: t('Line Style'), + renderTrigger: true, + choices: [ + ['linear', t('Linear')], + ['cardinal', t('Smooth')], + ['step-before', t('Step - start')], + ['step-after', t('Step - end')], + ], + default: 'linear', + description: t('Line interpolation as defined by d3.js'), + }, + }, + ], + ], + }, + { + label: t('X Axis'), + expanded: true, + controlSetRows: [ + [ + { + name: 'x_axis_label', + config: { + type: 'TextControl', + label: t('X Axis Label'), + renderTrigger: true, + default: '', + }, + }, + ], + [ + { + name: 'x_axis_format', + config: { + type: 'SelectControl', + freeForm: true, + label: t('X Axis Format'), + renderTrigger: true, + default: 'smart_date', + choices: D3_TIME_FORMAT_OPTIONS, + description: t('D3 time format for the x-axis labels'), + }, + }, + ], + ], + }, + { + label: t('Y Axis'), + expanded: true, + controlSetRows: [ + [ + { + name: 'y_axis_label', + config: { + type: 'TextControl', + label: t('Y Axis Label'), + renderTrigger: true, + default: '', + }, + }, + ], + ['y_axis_format'], + [ + { + name: 'y_log_scale', + config: { + type: 'CheckboxControl', + label: t('Y Log Scale'), + default: false, + renderTrigger: true, + description: t('Use a log scale for the Y-axis'), + }, + }, + ], + [ + { + name: 'y_axis_bounds', + config: { + type: 'BoundsControl', + label: t('Y Axis Bounds'), + renderTrigger: true, + default: [null, null], + description: t( + 'Bounds for the Y-axis. When left empty, the bounds are ' + + 'dynamically defined based on the min/max of the data. Note that ' + + "this feature will only expand the axis range. It won't " + + "narrow the data's extent.", + ), + }, + }, + ], + ], + }, + ], +}; + +export default config; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/images/example-dark.jpg b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/images/example-dark.jpg new file mode 100644 index 00000000000..6d92e0682b4 Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/images/example-dark.jpg differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/images/example.jpg b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/images/example.jpg new file mode 100644 index 00000000000..6b7868313c0 Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/images/example.jpg differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/images/thumbnail-dark.png b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/images/thumbnail-dark.png new file mode 100644 index 00000000000..0ab807f6049 Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/images/thumbnail-dark.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/images/thumbnail.png b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/images/thumbnail.png new file mode 100644 index 00000000000..dde48ae0246 Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/images/thumbnail.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/images/thumbnailLarge.png b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/images/thumbnailLarge.png new file mode 100644 index 00000000000..489eccd2945 Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/images/thumbnailLarge.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/index.ts new file mode 100644 index 00000000000..6930b3279ab --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/index.ts @@ -0,0 +1,55 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { t } from '@apache-superset/core/translation'; +import { Behavior } from '@superset-ui/core'; +import buildQuery from './buildQuery'; +import controlPanel from './controlPanel'; +import transformProps from './transformProps'; +import thumbnail from './images/thumbnail.png'; +import thumbnailDark from './images/thumbnail-dark.png'; +import example from './images/example.jpg'; +import exampleDark from './images/example-dark.jpg'; +import { EchartsTimePivotFormData, EchartsTimePivotChartProps } from './types'; +import { EchartsChartPlugin } from '../types'; + +export default class EchartsTimePivotChartPlugin extends EchartsChartPlugin< + EchartsTimePivotFormData, + EchartsTimePivotChartProps +> { + constructor() { + super({ + buildQuery, + controlPanel, + loadChart: () => import('./EchartsTimePivot'), + metadata: { + behaviors: [Behavior.InteractiveChart], + category: t('Evolution'), + description: t( + 'Compares the current time period against equivalent past periods by overlaying them on a shared time axis. The current period is drawn boldly while prior periods fade with age.', + ), + exampleGallery: [{ url: example, urlDark: exampleDark }], + name: t('Time-series Period Pivot'), + tags: [t('Comparison'), t('Time'), t('Trend'), t('ECharts')], + thumbnail, + thumbnailDark, + }, + transformProps, + }); + } +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/transformData.ts b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/transformData.ts new file mode 100644 index 00000000000..b1d21bb0af5 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/transformData.ts @@ -0,0 +1,187 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { DTTM_ALIAS } from '@superset-ui/core'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const WEEKDAYS = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']; + +/** + * Rolls a timestamp back to the start of its period the way pandas + * offset.rollback(normalize=True) does for the offset aliases the + * frequency control offers (AS/A, QS/Q, MS/M, W and W-XXX, D, H, T/MIN). + * The multiplier prefix (e.g. 52W-MON) does not change the anchor. + */ +export const rollback = (timestamp: number, freq: string): number => { + const match = /^\d*(AS|YS|A|Y|QS|Q|MS|M|W(?:-([A-Z]{3}))?|D|H|T|MIN)$/i.exec( + (freq || 'W-MON').trim(), + ); + const date = new Date(timestamp); + const midnight = Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate(), + ); + if (!match) { + return midnight; // freeform rules fall back to day boundaries + } + const unit = match[1].toUpperCase(); + if (unit === 'AS' || unit === 'YS') { + return Date.UTC(date.getUTCFullYear(), 0, 1); + } + if (unit === 'A' || unit === 'Y') { + // most recent Dec 31 at or before the timestamp + const yearEnd = Date.UTC(date.getUTCFullYear(), 11, 31); + return midnight >= yearEnd + ? yearEnd + : Date.UTC(date.getUTCFullYear() - 1, 11, 31); + } + if (unit === 'QS') { + return Date.UTC( + date.getUTCFullYear(), + Math.floor(date.getUTCMonth() / 3) * 3, + 1, + ); + } + if (unit === 'Q') { + // most recent quarter end at or before the timestamp + const quarterEndMonth = Math.floor(date.getUTCMonth() / 3) * 3 + 3; + const quarterEnd = Date.UTC(date.getUTCFullYear(), quarterEndMonth, 0); + return midnight >= quarterEnd + ? quarterEnd + : Date.UTC(date.getUTCFullYear(), quarterEndMonth - 3, 0); + } + if (unit === 'MS') { + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1); + } + if (unit === 'M') { + // most recent month end at or before the timestamp + const monthEnd = Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0); + return midnight >= monthEnd + ? monthEnd + : Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 0); + } + if (unit.startsWith('W')) { + const anchor = match[2] ? WEEKDAYS.indexOf(match[2].toUpperCase()) : 6; // W defaults to W-SUN + // JS getUTCDay: 0=SUN..6=SAT; WEEKDAYS index: 0=MON..6=SUN + const jsAnchor = anchor === 6 ? 0 : anchor + 1; + const diff = (date.getUTCDay() - jsAnchor + 7) % 7; + return midnight - diff * DAY_MS; + } + if (unit === 'D') { + return midnight; + } + if (unit === 'H') { + return timestamp - (timestamp % (60 * 60 * 1000)); + } + return timestamp - (timestamp % (60 * 1000)); // T / MIN +}; + +export interface TimePivotSeries { + key: string; + values: { x: number; y: number | null }[]; + rank: number; + perc: number; +} + +/** + * Ports the legacy NVD3TimePivotViz.get_data reshape: timestamps are + * bucketed into periods by the freq offset, ranked most-recent-first + * ("current", "-1", "-2", ...), shifted onto the latest period's time + * axis, and pivoted into one series per period with rank/perc metadata. + */ +export default function transformData( + records: Record[], + metricLabel: string, + freq: string, + periodLimit?: number, +): TimePivotSeries[] { + const rows = records + .filter(record => record[DTTM_ALIAS] != null) + .map(record => { + const timestamp = record[DTTM_ALIAS] as number; + return { + timestamp, + period: rollback(timestamp, freq), + value: record[metricLabel] as number | null, + }; + }); + if (rows.length === 0) { + return []; + } + + const periods = Array.from(new Set(rows.map(row => row.period))) + .sort((a, b) => b - a) + .slice( + 0, + // clamp to [1, available periods]; undefined keeps them all + periodLimit && periodLimit > 0 ? Math.floor(periodLimit) : undefined, + ); + const maxPeriod = periods[0]; + const rankOf = new Map(periods.map((period, index) => [period, index])); + const maxRank = periods.length - 1; + + const seriesOf = (rank: number) => (rank === 0 ? 'current' : `-${rank}`); + + // shift every point onto the latest period's time axis + const values = new Map>(); + const shiftedTimestamps = new Set(); + rows.forEach(({ timestamp, period, value }) => { + if (!rankOf.has(period)) { + return; // period dropped by the limit + } + const series = seriesOf(rankOf.get(period)!); + const shifted = timestamp + (maxPeriod - period); + shiftedTimestamps.add(shifted); + if (!values.has(series)) { + values.set(series, new Map()); + } + values.get(series)!.set(shifted, value); + }); + + const timestamps = Array.from(shiftedTimestamps).sort((a, b) => a - b); + // pandas pivot sorts the series labels lexicographically + const seriesLabels = Array.from(values.keys()).sort(); + + const chartData: TimePivotSeries[] = []; + seriesLabels.forEach(series => { + const seriesValues = values.get(series)!; + let nonNullCount = 0; + const points = timestamps.map(timestamp => { + let y = seriesValues.has(timestamp) ? seriesValues.get(timestamp)! : null; + if (typeof y === 'number' && Number.isNaN(y)) { + y = null; + } + if (y != null) { + nonNullCount += 1; + } + return { x: timestamp, y }; + }); + if (nonNullCount === 0) { + return; + } + const rank = series === 'current' ? 0 : Number(series.slice(1)); + chartData.push({ + key: series, + values: points, + rank, + perc: 1 - rank / (maxRank + 1), + }); + }); + return chartData; +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/transformProps.ts new file mode 100644 index 00000000000..dc33d054204 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/transformProps.ts @@ -0,0 +1,197 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + getMetricLabel, + getNumberFormatter, + getTimeFormatter, + JsonObject, + SMART_DATE_VERBOSE_ID, +} from '@superset-ui/core'; +import type { EChartsCoreOption } from 'echarts/core'; +import { Refs } from '../types'; +import { calculateLowerLogTick } from '../utils/series'; +import transformData, { TimePivotSeries } from './transformData'; +import { + EchartsTimePivotChartProps, + TimePivotChartTransformedProps, +} from './types'; + +const DEFAULT_COLOR = { r: 0, g: 122, b: 135, a: 1 }; + +export default function transformProps( + chartProps: EchartsTimePivotChartProps, +): TimePivotChartTransformedProps { + const { width, height, formData, queriesData, theme, hooks } = chartProps; + const { + metric, + freq, + periodLimit, + colorPicker, + showLegend, + lineInterpolation, + xAxisFormat, + xAxisLabel, + yAxisLabel, + yAxisFormat, + yLogScale, + yAxisBounds, + } = formData; + const refs: Refs = {}; + const { onContextMenu, setDataMask = () => {} } = hooks; + + const metricLabel = getMetricLabel(metric ?? ''); + const records = (queriesData[0]?.data ?? []) as Record[]; + const series: TimePivotSeries[] = Array.isArray(records) + ? transformData( + records, + metricLabel, + (freq as string) || 'W-MON', + periodLimit ? Number(periodLimit) : undefined, + ) + : []; + + const { r, g, b } = colorPicker ?? DEFAULT_COLOR; + // Match the nvd3 styling: the current period is fully opaque, prior + // periods fade with their recency percentile. + const colorOf = (s: TimePivotSeries) => + `rgba(${r}, ${g}, ${b}, ${s.rank > 0 ? s.perc * 0.5 : 1})`; + + const smooth = lineInterpolation === 'cardinal'; + const step = + lineInterpolation === 'step-before' + ? 'start' + : lineInterpolation === 'step-after' + ? 'end' + : undefined; + + const valueFormatter = getNumberFormatter(yAxisFormat); + const timeFormatter = getTimeFormatter(SMART_DATE_VERBOSE_ID); + + // Draw the current period last so it paints on top of the faded priors. + const sortedSeries = [...series].sort((a, b) => b.rank - a.rank); + + const [, yMax] = yAxisBounds ?? [null, null]; + let yMin = yAxisBounds?.[0] ?? null; + // ECharts log axes only accept strictly positive values, so anchor the + // default minimum to the smallest positive value rather than letting the + // axis include zero/negative territory it cannot render. + if (yLogScale && yMin == null) { + const minPositiveValue = Math.min( + ...series.flatMap(s => + s.values + .map(({ y }) => y) + .filter((y): y is number => y != null && y > 0), + ), + ); + if (Number.isFinite(minPositiveValue)) { + yMin = calculateLowerLogTick(minPositiveValue); + } + } + + const echartOptions: EChartsCoreOption = { + grid: { + top: theme.sizeUnit * 8, + bottom: theme.sizeUnit * 8, + left: theme.sizeUnit * 4, + right: theme.sizeUnit * 6, + containLabel: true, + }, + legend: { + show: showLegend !== false, + top: 0, + data: series.map(s => s.key), + }, + xAxis: { + type: 'time', + name: xAxisLabel || undefined, + nameLocation: 'middle', + nameGap: theme.sizeUnit * 8, + axisLabel: { + color: theme.colorTextSecondary, + ...(xAxisFormat && xAxisFormat !== 'smart_date' + ? { + formatter: (value: number) => + getTimeFormatter(xAxisFormat)(value), + } + : {}), + }, + }, + yAxis: { + type: yLogScale ? 'log' : 'value', + name: yAxisLabel || undefined, + nameLocation: 'middle', + nameGap: theme.sizeUnit * 12, + min: yMin ?? undefined, + max: yMax ?? undefined, + axisLabel: { + color: theme.colorTextSecondary, + formatter: (value: number) => valueFormatter(value), + }, + }, + tooltip: { + trigger: 'axis', + confine: true, + formatter: (params: JsonObject[]) => { + const rows = params + .filter(param => param.value?.[1] != null) + .map( + param => + `${param.marker}${param.seriesName}: ${valueFormatter( + param.value[1] as number, + )}`, + ); + const axisTime = params[0]?.value?.[0]; + return [ + axisTime != null ? timeFormatter(axisTime as number) : '', + ...rows, + ].join('
'); + }, + }, + series: sortedSeries.map(s => ({ + name: s.key, + type: 'line', + smooth, + ...(step ? { step } : {}), + // a line series with one point renders nothing without its symbol; + // sparse periods (e.g. yearly data pivoted by 52 weeks) need them + showSymbol: s.values.filter(({ y }) => y != null).length <= 2, + connectNulls: false, + lineStyle: { + color: colorOf(s), + width: s.rank === 0 ? 3 : 2, + }, + itemStyle: { color: colorOf(s) }, + data: s.values.map(({ x, y }) => [x, y]), + z: s.rank === 0 ? 10 : 2, + })), + }; + + return { + width, + height, + echartOptions, + formData, + onContextMenu, + setDataMask, + selectedValues: {}, + groupby: [], + labelMap: {}, + refs, + }; +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/types.ts new file mode 100644 index 00000000000..18075445e59 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/TimePivot/types.ts @@ -0,0 +1,48 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { QueryFormData, QueryFormMetric } from '@superset-ui/core'; +import { + BaseChartProps, + BaseTransformedProps, + CrossFilterTransformedProps, +} from '../types'; + +export type EchartsTimePivotFormData = QueryFormData & { + metric?: QueryFormMetric; + /** pandas-style period offset, e.g. W-MON, D, AS */ + freq?: string; + /** keep only the N most recent periods; empty keeps all */ + periodLimit?: number | string; + colorPicker?: { r: number; g: number; b: number; a: number }; + showLegend?: boolean; + lineInterpolation?: string; + xAxisFormat?: string; + xAxisLabel?: string; + yAxisLabel?: string; + yAxisFormat?: string; + yLogScale?: boolean; + yAxisBounds?: [number | null, number | null]; +}; + +export interface EchartsTimePivotChartProps extends BaseChartProps { + formData: EchartsTimePivotFormData; +} + +export type TimePivotChartTransformedProps = + BaseTransformedProps & CrossFilterTransformedProps; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts index abb57d9e6e8..fd50636e275 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts @@ -17,6 +17,7 @@ * under the License. */ export { default as EchartsBoxPlotChartPlugin } from './BoxPlot'; +export { default as EchartsTimePivotChartPlugin } from './TimePivot'; export { default as EchartsTimeseriesChartPlugin } from './Timeseries'; export { default as EchartsAreaChartPlugin } from './Timeseries/Area'; export { default as EchartsTimeseriesBarChartPlugin } from './Timeseries/Regular/Bar'; diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/TimePivot/transformData.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/TimePivot/transformData.test.ts new file mode 100644 index 00000000000..cf22e578cf7 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/TimePivot/transformData.test.ts @@ -0,0 +1,108 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { QueryFormData } from '@superset-ui/core'; +import buildQuery from '../../src/TimePivot/buildQuery'; +import transformData, { rollback } from '../../src/TimePivot/transformData'; + +test('buildQuery builds a timeseries query with the single metric', () => { + const formData: QueryFormData = { + datasource: '5__table', + granularity_sqla: 'ds', + time_range: 'Last quarter', + viz_type: 'time_pivot', + metric: 'sum__num', + freq: 'W-MON', + }; + const [query] = buildQuery(formData).queries; + expect(query.metrics).toEqual(['sum__num']); + expect(query.is_timeseries).toBe(true); +}); + +describe('TimePivot rollback', () => { + const wed = Date.UTC(2024, 0, 10, 15, 30); // Wednesday 2024-01-10 + test.each([ + ['W-MON', Date.UTC(2024, 0, 8)], + ['52W-MON', Date.UTC(2024, 0, 8)], + ['W-SUN', Date.UTC(2024, 0, 7)], + ['D', Date.UTC(2024, 0, 10)], + ['AS', Date.UTC(2024, 0, 1)], + ['QS', Date.UTC(2024, 0, 1)], + ['MS', Date.UTC(2024, 0, 1)], + ])('rolls back to the %s period start', (freq, expected) => { + expect(rollback(wed, freq as string)).toEqual(expected); + }); + + test('rolls back to the most recent month end for M', () => { + expect(rollback(wed, 'M')).toEqual(Date.UTC(2023, 11, 31)); + expect(rollback(Date.UTC(2024, 0, 31), 'M')).toEqual(Date.UTC(2024, 0, 31)); + }); +}); + +describe('TimePivot transformData', () => { + const mon1 = Date.UTC(2024, 0, 1); // Monday + const tue1 = Date.UTC(2024, 0, 2); + const mon2 = Date.UTC(2024, 0, 8); // next Monday + const tue2 = Date.UTC(2024, 0, 9); + + test('pivots periods onto the latest period axis with ranks', () => { + const data = transformData( + [ + { __timestamp: mon1, sum__num: 1 }, + { __timestamp: tue1, sum__num: 2 }, + { __timestamp: mon2, sum__num: 3 }, + { __timestamp: tue2, sum__num: 4 }, + ], + 'sum__num', + 'W-MON', + ); + expect(data.map(series => series.key)).toEqual(['-1', 'current']); + const previous = data[0]; + const current = data[1]; + expect(previous.rank).toEqual(1); + expect(previous.perc).toEqual(0.5); + expect(current.rank).toEqual(0); + expect(current.perc).toEqual(1); + // the older week is shifted onto the current week's timestamps + expect(previous.values).toEqual([ + { x: mon2, y: 1 }, + { x: tue2, y: 2 }, + ]); + expect(current.values).toEqual([ + { x: mon2, y: 3 }, + { x: tue2, y: 4 }, + ]); + }); + + test('returns an empty list for empty input', () => { + expect(transformData([], 'sum__num', 'W-MON')).toEqual([]); + }); +}); + +test('limits to the N most recent periods, clamped to what exists', () => { + const WEEK = 7 * 24 * 3600 * 1000; + const M1 = 1578268800000; + const records = [0, 1, 2, 3].map(i => ({ + __timestamp: M1 + i * WEEK, + m: 10 + i, + })); + const limited = transformData(records, 'm', 'W-MON', 2); + expect(limited.map(s => s.key).sort()).toEqual(['-1', 'current']); + // clamped: asking for more than available keeps them all + expect(transformData(records, 'm', 'W-MON', 99)).toHaveLength(4); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/TimePivot/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/TimePivot/transformProps.test.ts new file mode 100644 index 00000000000..a83d261d1e9 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/TimePivot/transformProps.test.ts @@ -0,0 +1,92 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { ChartProps, SqlaFormData, VizType } from '@superset-ui/core'; +import { supersetTheme } from '@apache-superset/core/theme'; +import transformProps from '../../src/TimePivot/transformProps'; +import { EchartsTimePivotChartProps } from '../../src/TimePivot/types'; + +const WEEK = 7 * 24 * 3600 * 1000; +// Mondays (UTC): 2020-01-06 and 2020-01-13 +const MONDAY_1 = 1578268800000; +const MONDAY_2 = MONDAY_1 + WEEK; + +const formData: SqlaFormData = { + datasource: '5__table', + viz_type: VizType.TimePivot, + metric: 'sum__num', + freq: 'W-MON', + colorPicker: { r: 0, g: 122, b: 135, a: 1 }, +}; + +const chartProps = (overrides: Partial = {}) => + new ChartProps({ + width: 800, + height: 400, + formData: { ...formData, ...overrides }, + theme: supersetTheme, + queriesData: [ + { + data: [ + { __timestamp: MONDAY_1, sum__num: 10 }, + { __timestamp: MONDAY_2, sum__num: 20 }, + ], + }, + ], + hooks: {}, + }) as unknown as EchartsTimePivotChartProps; + +test('pivots periods into one line series each, current on top', () => { + const { echartOptions } = transformProps(chartProps()); + const { series } = echartOptions as any; + + expect(series).toHaveLength(2); + // drawn prior-first so "current" paints on top + expect(series.map((s: any) => s.name)).toEqual(['-1', 'current']); + + const current = series[1]; + expect(current.type).toBe('line'); + expect(current.lineStyle.color).toBe('rgba(0, 122, 135, 1)'); + // prior period shifted onto the current period's axis, faded + const prior = series[0]; + expect(prior.data[0][0]).toBe(MONDAY_2); + expect(prior.lineStyle.color).toMatch(/rgba\(0, 122, 135, 0\.2/); +}); + +test('honors log scale and y-axis bounds', () => { + const { echartOptions } = transformProps( + chartProps({ yLogScale: true, yAxisBounds: [1, 100] }), + ); + const { yAxis } = echartOptions as any; + expect(yAxis.type).toBe('log'); + expect(yAxis.min).toBe(1); + expect(yAxis.max).toBe(100); +}); + +test('handles an empty result without crashing', () => { + const props = new ChartProps({ + width: 800, + height: 400, + formData, + theme: supersetTheme, + queriesData: [{ data: [] }], + hooks: {}, + }) as unknown as EchartsTimePivotChartProps; + const { echartOptions } = transformProps(props); + expect((echartOptions as any).series).toEqual([]); +}); diff --git a/superset-frontend/src/visualizations/presets/MainPreset.ts b/superset-frontend/src/visualizations/presets/MainPreset.ts index 5aa9c9b8576..99b4150a3c4 100644 --- a/superset-frontend/src/visualizations/presets/MainPreset.ts +++ b/superset-frontend/src/visualizations/presets/MainPreset.ts @@ -33,10 +33,7 @@ import RoseChartPlugin from '@superset-ui/plugin-chart-rose'; import TableChartPlugin from '@superset-ui/plugin-chart-table'; import { WordCloudChartPlugin } from '@superset-ui/plugin-chart-word-cloud'; import WorldMapChartPlugin from '@superset-ui/plugin-chart-world-map'; -import { - BulletChartPlugin, - TimePivotChartPlugin, -} from '@superset-ui/preset-chart-nvd3'; +import { BulletChartPlugin } from '@superset-ui/preset-chart-nvd3'; import { DeckGLChartPreset } from '@superset-ui/preset-chart-deckgl'; import ScatterMapChartPlugin from '@superset-ui/plugin-chart-point-cluster-map'; import { CartodiagramPlugin } from '@superset-ui/plugin-chart-cartodiagram'; @@ -67,6 +64,7 @@ import { BigNumberPeriodOverPeriodChartPlugin, EchartsHeatmapChartPlugin, EchartsGanttChartPlugin, + EchartsTimePivotChartPlugin, } from '@superset-ui/plugin-chart-echarts'; import { SelectFilterPlugin, @@ -137,7 +135,7 @@ export default class MainPreset extends Preset { new PivotTableChartPluginV2().configure({ key: VizType.PivotTable }), new RoseChartPlugin().configure({ key: VizType.Rose }), new TableChartPlugin().configure({ key: VizType.Table }), - new TimePivotChartPlugin().configure({ key: VizType.TimePivot }), + new EchartsTimePivotChartPlugin().configure({ key: VizType.TimePivot }), new TimeTableChartPlugin().configure({ key: VizType.TimeTable }), new WordCloudChartPlugin().configure({ key: VizType.WordCloud }), new WorldMapChartPlugin().configure({ key: VizType.WorldMap }),