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 }),